Compare commits

20 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
110 changed files with 7592 additions and 1782 deletions
+5
View File
@@ -27,3 +27,8 @@ APP_JWT_SECRET=local-dev-secret-change-me-please-32chars-min
# Adres bazowy w linkach z e-maili (aktywacja konta, reset hasla).
APP_PUBLIC_BASE_URL=http://localhost
# --- Platnosci ---
# true = bez skonfigurowanej bramki AutoPay zakup promowania jest potwierdzany automatycznie.
# Wylacznie lokalnie (docker-compose.yml). Produkcja tej zmiennej nie czyta.
APP_PAYMENTS_AUTO_CONFIRM=true
+4 -3
View File
@@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<version>3.5.16</version>
<relativePath/>
</parent>
@@ -64,9 +64,10 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Testy na prawdziwym PostgreSQL (tymczasowy kontener), tak jak produkcja - bez emulacji H2. -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
@@ -38,11 +38,13 @@ import pl.polskalokalnie.lead.AdvertisingEnquiryResponse;
import pl.polskalokalnie.lead.AdvertisingEnquiryService;
import pl.polskalokalnie.support.SupportMessageResponse;
import pl.polskalokalnie.support.SupportMessageService;
import pl.polskalokalnie.user.AccountErasureService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.BlockedEmail;
import pl.polskalokalnie.user.BlockedEmailRepository;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
@RestController
@RequestMapping("/api/admin")
@@ -64,6 +66,8 @@ public class AdminController {
private final AdminStatsService adminStatsService;
private final PasswordEncoder passwordEncoder;
private final NotificationService notificationService;
private final AccountErasureService accountErasureService;
private final UserSettingsService userSettingsService;
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
ListingService listingService, ListingReportService listingReportService,
@@ -74,7 +78,9 @@ public class AdminController {
TextModerationService textModerationService,
AdminStatsService adminStatsService,
PasswordEncoder passwordEncoder,
NotificationService notificationService) {
NotificationService notificationService,
AccountErasureService accountErasureService,
UserSettingsService userSettingsService) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
this.listingService = listingService;
@@ -87,6 +93,8 @@ public class AdminController {
this.adminStatsService = adminStatsService;
this.passwordEncoder = passwordEncoder;
this.notificationService = notificationService;
this.accountErasureService = accountErasureService;
this.userSettingsService = userSettingsService;
}
// --- Uzytkownicy ---
@@ -166,6 +174,8 @@ public class AdminController {
String temporaryPassword = generateTemporaryPassword();
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
// Reset przez administracje (np. przy przejetym koncie) konczy wszystkie sesje uzytkownika.
user.revokeSessions();
userRepository.save(user);
notificationService.create(user.getEmail(), "system", "lock",
"Hasło zostało zresetowane",
@@ -181,6 +191,7 @@ public class AdminController {
if (user.getRole() == Role.ADMIN) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
}
listingService.deleteAllOfOwner(user.getEmail());
userRepository.delete(user);
}
@@ -71,7 +71,7 @@ public class AccountEmailService {
boolean approved, String reason, String path) {
String link = baseUrl + (path == null ? "" : path);
String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
String safeTitle = articleTitle == null ? "" : articleTitle;
String safeTitle = articleTitle == null ? "" : HtmlUtils.htmlEscape(articleTitle);
String body;
if (approved) {
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
@@ -80,7 +80,7 @@ public class AccountEmailService {
} else {
String reasonHtml = reason == null || reason.isBlank() ? ""
: "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> "
+ reason.trim() + "</p>";
+ HtmlUtils.htmlEscape(reason.trim()) + "</p>";
body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół"
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
+ reasonHtml
@@ -143,8 +143,10 @@ public class AccountEmailService {
}
}
private static String greeting(String name) {
String who = name == null || name.isBlank() ? "" : " " + name.trim().split("\\s+")[0];
// Imie pochodzi z formularza rejestracji (kazdy moze zarejestrowac cudzy adres), wiec jest
// escapowane - inaczej prawdziwy mail z serwisu niosl by link lub tresc atakujacego.
static String greeting(String name) {
String who = name == null || name.isBlank() ? "" : " " + HtmlUtils.htmlEscape(name.trim().split("\\s+")[0]);
return "<p>Cześć" + who + ",</p>";
}
@@ -112,10 +112,10 @@ public class AuthController {
// --- Operacje na wlasnym koncie z /konto/ustawienia ---
// Zmiana hasla konczy wszystkie dotychczasowe sesje - odpowiedz niesie nowy token dla biezacej.
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
return ResponseEntity.noContent().build();
public AuthResponse changePassword(Authentication authentication, @Valid @RequestBody ChangePasswordRequest request) {
return authService.changePassword(authentication.getName(), request.currentPassword(), request.newPassword());
}
/**
@@ -3,8 +3,6 @@ package pl.polskalokalnie.auth;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.Locale;
@@ -14,6 +12,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.lead.LeadSyncService;
import pl.polskalokalnie.listing.ListingService;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest;
@@ -21,6 +20,7 @@ import pl.polskalokalnie.auth.dto.RegisterRequest;
import pl.polskalokalnie.auth.dto.RegisterResponse;
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
import pl.polskalokalnie.auth.dto.UserResponse;
import pl.polskalokalnie.user.AccountErasureService;
import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.AuthProvider;
@@ -52,6 +52,8 @@ public class AuthService {
private final PhoneVerificationService phoneVerificationService;
private final NickService nickService;
private final UserSettingsService userSettingsService;
private final AccountErasureService accountErasureService;
private final ListingService listingService;
public AuthService(
UserRepository userRepository,
@@ -64,7 +66,9 @@ public class AuthService {
AccountEmailService accountEmailService,
PhoneVerificationService phoneVerificationService,
NickService nickService,
UserSettingsService userSettingsService
UserSettingsService userSettingsService,
AccountErasureService accountErasureService,
ListingService listingService
) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
@@ -77,6 +81,8 @@ public class AuthService {
this.phoneVerificationService = phoneVerificationService;
this.nickService = nickService;
this.userSettingsService = userSettingsService;
this.accountErasureService = accountErasureService;
this.listingService = listingService;
}
@Transactional
@@ -104,7 +110,14 @@ public class AuthService {
String phone = request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null;
user.setPhone(phone);
user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null);
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
// Adres zapisujemy tylko firmom - konto prywatne go nie potrzebuje (minimalizacja danych).
if (user.getAccountType() == AccountType.COMPANY) {
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
}
// Rejestracja jest mozliwa tylko po zaznaczeniu zgody w formularzu - zapisujemy jej moment.
if (Boolean.TRUE.equals(request.acceptTerms())) {
user.setTermsAcceptedAt(Instant.now());
}
// Konto wymaga aktywacji przez uzytkownika (link e-mail). Do tego czasu logowanie jest zablokowane.
user.setVerified(false);
user.setPhoneVerified(false);
@@ -191,6 +204,8 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(authToken.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
user.setPasswordHash(passwordEncoder.encode(newPassword));
// Reset hasla konczy wszystkie dotychczasowe sesje - takze te przejete przez intruza.
user.revokeSessions();
// Reset hasla potwierdza tez adres e-mail - konto staje sie aktywne.
user.setVerified(true);
userRepository.save(user);
@@ -210,8 +225,12 @@ public class AuthService {
*/
public void resendPhoneOtp(String email) {
String normalized = normalizeEmail(email);
AppUser user = userRepository.findByEmailIgnoreCase(normalized)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
// Endpoint jest publiczny - dla nieznanego adresu odpowiadamy tak samo jak po wyslaniu kodu,
// zeby nie dalo sie nim sprawdzac, czy dany e-mail ma konto w serwisie.
AppUser user = userRepository.findByEmailIgnoreCase(normalized).orElse(null);
if (user == null) {
return;
}
if (user.getPhone() == null || user.getPhone().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Konto nie ma przypisanego numeru telefonu");
}
@@ -289,7 +308,7 @@ public class AuthService {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.birthDate(), request.address());
textModerationService.validateOrThrow(request.fullName(), request.nick(), request.phone(), request.address());
user.setFullName(request.fullName().trim());
if (request.nick() != null && !request.nick().isBlank()) {
@@ -303,22 +322,20 @@ public class AuthService {
user.setPhoneVerified(false);
}
user.setPhone(phone);
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
// Adres przyjmujemy wylacznie od kont firmowych (siedziba przedsiebiorcy). Konto prywatne
// nie ma po co podawac adresu zamieszkania, wiec nawet przyslany z zewnatrz zostaje odrzucony
// i kasuje to, co ewentualnie zapisano wczesniej.
if (user.getAccountType() == AccountType.COMPANY) {
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
} else {
user.setAddress(null);
}
// NIP: null = nie zmieniaj (zapis czesciowy nie moze skasowac NIP-u firmy), pusty = wyczysc.
if (request.nip() != null) {
user.setNip(request.nip().isBlank() ? null : request.nip().trim());
}
user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
if (request.birthDate() != null && !request.birthDate().isBlank()) {
try {
user.setBirthDate(LocalDate.parse(request.birthDate().trim()));
} catch (DateTimeParseException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy format daty urodzenia");
}
} else {
user.setBirthDate(null);
}
AppUser saved = userRepository.save(user);
leadSyncService.syncUser(saved);
@@ -330,7 +347,7 @@ public class AuthService {
* wiec przejeta sesja nie pozwala przejac konta na stale.
*/
@Transactional
public void changePassword(String email, String currentPassword, String newPassword) {
public AuthResponse changePassword(String email, String currentPassword, String newPassword) {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
@@ -357,6 +374,8 @@ public class AuthService {
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setPasswordChangedAt(Instant.now());
// Inne sesje (np. przejete) koncza sie od razu; biezaca dostaje nowy token w odpowiedzi.
user.revokeSessions();
userRepository.save(user);
// Niewykorzystane linki resetu przestaja dzialac po recznej zmianie hasla.
@@ -366,6 +385,7 @@ public class AuthService {
authTokenRepository.save(old);
}
});
return buildAuthResponse(user);
}
/**
@@ -429,7 +449,9 @@ public class AuthService {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Hasło jest nieprawidłowe");
}
accountErasureService.eraseFor(user.getId(), user.getEmail());
userSettingsService.deleteForUser(user.getId());
listingService.deleteAllOfOwner(user.getEmail());
userRepository.delete(user);
}
@@ -0,0 +1,49 @@
package pl.polskalokalnie.auth;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* Sprzatanie jednorazowych tokenow i kodow SMS. Wczesniej zostawaly w bazie bezterminowo,
* mimo ze po wygasnieciu sa bezuzyteczne - a wiersz phone_otps zawiera numer telefonu
* i kod w jawnej postaci. Zasada ograniczenia przechowywania (art. 5 ust. 1 lit. e RODO)
* wymaga, zeby znikaly, gdy przestaja byc potrzebne.
*
* <p>Zostawiamy krotki bufor od wygasniecia: uzytkownik, ktory kliknie stary link, dostaje
* czytelny komunikat zamiast bledu "nie znaleziono".</p>
*/
@Component
public class ExpiredTokenCleaner {
private static final Logger log = LoggerFactory.getLogger(ExpiredTokenCleaner.class);
/** Bufor po wygasnieciu - tyle jeszcze trzymamy zuzyty albo przeterminowany wpis. */
private static final int GRACE_DAYS = 7;
private final JdbcTemplate jdbcTemplate;
public ExpiredTokenCleaner(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Scheduled(cron = "0 15 3 * * *")
@Transactional
public void cleanDaily() {
Instant cutoff = Instant.now().minus(GRACE_DAYS, ChronoUnit.DAYS);
try {
int tokens = jdbcTemplate.update("DELETE FROM auth_tokens WHERE expires_at < ?", cutoff);
int otps = jdbcTemplate.update("DELETE FROM phone_otps WHERE expires_at < ?", cutoff);
if (tokens > 0 || otps > 0) {
log.info("Sprzatanie: usunieto {} wygaslych tokenow i {} kodow SMS", tokens, otps);
}
} catch (RuntimeException ex) {
log.warn("Sprzatanie wygaslych tokenow nie powiodlo sie: {}", ex.getMessage());
}
}
}
@@ -6,6 +6,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -14,14 +15,20 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtService jwtService;
static final String STREAM_PATH = "/api/notifications/stream";
public JwtAuthFilter(JwtService jwtService) {
private final JwtService jwtService;
private final UserRepository userRepository;
public JwtAuthFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService;
this.userRepository = userRepository;
}
@Override
@@ -30,16 +37,29 @@ public class JwtAuthFilter extends OncePerRequestFilter {
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null) {
String header = request.getHeader("Authorization");
boolean fromHeader = header != null && header.startsWith("Bearer ");
// Token w adresie tylko dla strumienia powiadomien (EventSource nie ustawia naglowkow) i tylko
// krotki token strumienia - token sesji w adresie trafial do logow serwera.
String token = fromHeader ? header.substring(7)
: STREAM_PATH.equals(request.getRequestURI()) ? request.getParameter("ticket") : null;
if (token != null && !token.isBlank()) {
try {
Claims claims = jwtService.parse(token);
String email = claims.getSubject();
String role = claims.get("role", String.class);
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
boolean streamToken = JwtService.PURPOSE_STREAM.equals(claims.get("purpose", String.class));
if (streamToken == fromHeader) {
// Token strumienia nie jest sesja, a sesji nie przyjmujemy z adresu.
throw new IllegalArgumentException("Token uzyty niezgodnie z przeznaczeniem");
}
// Stan konta bierzemy z bazy, a nie z tokenu: zablokowanie, usuniecie konta, odebranie
// roli administratora i zmiana hasla dzialaja od razu, a nie dopiero po wygasnieciu tokenu.
AppUser user = userRepository.findByEmailIgnoreCase(claims.getSubject()).orElse(null);
if (user != null && isSessionValid(user, claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant())) {
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name()));
var authentication = new UsernamePasswordAuthenticationToken(user.getEmail(), null, authorities);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
SecurityContextHolder.clearContext();
@@ -48,16 +68,12 @@ public class JwtAuthFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
// Token z naglowka Authorization, a dla SSE (EventSource nie ustawia naglowkow) - z parametru access_token.
private String resolveToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
return header.substring(7);
static boolean isSessionValid(AppUser user, Instant issuedAt) {
if (user.isBlocked() || user.getRole() == null) {
return false;
}
String param = request.getParameter("access_token");
if (param != null && !param.isBlank()) {
return param;
}
return null;
Instant validFrom = user.getSessionsValidFrom();
return validFrom == null || (issuedAt != null && !issuedAt.isBefore(validFrom));
}
}
@@ -38,6 +38,26 @@ public class JwtService {
.compact();
}
/** Przeznaczenie tokenu strumienia powiadomien (claim "purpose"). */
public static final String PURPOSE_STREAM = "sse";
private static final long STREAM_TOKEN_SECONDS = 60;
/**
* Krotki (60 s) token wylacznie do otwarcia strumienia powiadomien. EventSource nie ustawia
* naglowkow, wiec token idzie w adresie - a adresy laduja w logach. Dlatego nie jest to token
* sesji: dziala tylko na /api/notifications/stream i wygasa po minucie.
*/
public String generateStreamToken(String email) {
Instant now = Instant.now();
return Jwts.builder()
.subject(email)
.claim("purpose", PURPOSE_STREAM)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(STREAM_TOKEN_SECONDS)))
.signWith(key)
.compact();
}
public Claims parse(String token) {
return Jwts.parser()
.verifyWith(key)
@@ -39,18 +39,24 @@ public class SecurityConfig {
.requestMatchers("/api/auth/activate", "/api/auth/resend-activation",
"/api/auth/forgot-password", "/api/auth/reset-password",
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
.requestMatchers("/api/i18n/translate").permitAll()
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
// GET pokazuje pytanie o potwierdzenie, POST wypisuje (takze z przycisku programu pocztowego).
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
.requestMatchers(HttpMethod.POST, "/api/unsubscribe").permitAll()
.requestMatchers("/api/auth/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
// Prywatne listy pod /api/listings - przed publiczna regula GET /api/listings/**,
// ktora inaczej przepuszczala je anonimowo (konczylo sie bledem 500).
.requestMatchers(HttpMethod.GET, "/api/listings/mine", "/api/listings/mine/**",
"/api/listings/favorites").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
// Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET.
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
// Historia srednich cen z ogloszen - publiczna, jak same ogloszenia.
.requestMatchers(HttpMethod.GET, "/api/price-history").permitAll()
// Wskazniki rynkowe kalkulatora zdolnosci - dane publiczne z GUS i Eurostatu.
.requestMatchers(HttpMethod.GET, "/api/market-indicators").permitAll()
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
@@ -7,11 +7,13 @@ import pl.polskalokalnie.user.AccountType;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 6, max = 72) String password,
@NotBlank String fullName,
@NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 120) String fullName,
AccountType accountType,
String phone,
String nip,
String address
String address,
/** Potwierdzenie akceptacji Regulaminu i Polityki prywatnosci - zapisujemy moment zgody. */
Boolean acceptTerms
) {
}
@@ -1,15 +1,16 @@
package pl.polskalokalnie.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import pl.polskalokalnie.user.ContactPreference;
import pl.polskalokalnie.user.PreferredLanguage;
public record UpdateProfileRequest(
@NotBlank String fullName,
@NotBlank @Size(max = 120) String fullName,
// Pusty nick oznacza "nie zmieniaj" - dotychczasowa nazwa uzytkownika zostaje.
String nick,
String phone,
String birthDate,
// Adres siedziby - przyjmowany tylko od kont firmowych.
String address,
// NIP firmy. null = nie zmieniaj (zapisy czesciowe, np. jezyka, nie kasuja NIP-u);
// pusty ciag = wyczysc. Dotyczy kont firmowych.
@@ -1,7 +1,6 @@
package pl.polskalokalnie.auth.dto;
import java.time.Instant;
import java.time.LocalDate;
import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.AuthProvider;
@@ -22,7 +21,6 @@ public record UserResponse(
ContactPreference contactPreference,
PreferredLanguage preferredLanguage,
String nip,
LocalDate birthDate,
boolean verified,
boolean phoneVerified,
boolean blocked,
@@ -50,7 +48,6 @@ public record UserResponse(
user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE,
user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL,
user.getNip(),
user.getBirthDate(),
user.isVerified(),
user.isPhoneVerified(),
user.isBlocked(),
@@ -0,0 +1,203 @@
package pl.polskalokalnie.campaign;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Limit;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.util.HtmlUtils;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
import pl.polskalokalnie.mailconfig.SmtpConfig;
import pl.polskalokalnie.send.EmailSender;
import pl.polskalokalnie.send.SendResult;
import pl.polskalokalnie.send.SmsSender;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
/**
* Wysylka kampanii z outboxu. Nic nie rusza sama z siebie - wysyla wylacznie to, co administrator
* zaplanowal w panelu, porcjami i w granicach dziennego limitu kampanii.
*
* <p>Tuz przed wyslaniem kazdy odbiorca jest sprawdzany ponownie: zgoda mogla zostac cofnieta,
* a rezygnacja przyjeta juz po zbudowaniu outboxu. Taki odbiorca dostaje status OPTED_OUT.</p>
*/
@Component
public class CampaignSender {
private static final Logger log = LoggerFactory.getLogger(CampaignSender.class);
/** Ile pozycji outboxu przetwarzamy na jeden przebieg - reszta poczeka minute. */
private static final int BATCH_SIZE = 25;
private final CampaignRepository campaignRepository;
private final CampaignRecipientRepository recipientRepository;
private final LeadRepository leadRepository;
private final MailConfigService mailConfigService;
private final EmailSender emailSender;
private final SmsSender smsSender;
private final UnsubscribeService unsubscribeService;
private final String baseUrl;
public CampaignSender(CampaignRepository campaignRepository,
CampaignRecipientRepository recipientRepository,
LeadRepository leadRepository,
MailConfigService mailConfigService,
EmailSender emailSender,
SmsSender smsSender,
UnsubscribeService unsubscribeService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
this.campaignRepository = campaignRepository;
this.recipientRepository = recipientRepository;
this.leadRepository = leadRepository;
this.mailConfigService = mailConfigService;
this.emailSender = emailSender;
this.smsSender = smsSender;
this.unsubscribeService = unsubscribeService;
this.baseUrl = baseUrl == null ? "http://localhost" : baseUrl.replaceAll("/+$", "");
}
@Scheduled(fixedDelay = 60_000, initialDelay = 30_000)
@Transactional
public void processOutbox() {
Instant now = Instant.now();
campaignRepository.findByStatus(CampaignStatus.SCHEDULED).stream()
.filter(campaign -> campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now))
.forEach(campaign -> {
campaign.setStatus(CampaignStatus.SENDING);
campaignRepository.save(campaign);
});
for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) {
try {
sendBatch(campaign);
} catch (RuntimeException ex) {
log.warn("Kampania {}: przebieg wysylki nie powiodl sie: {}", campaign.getId(), ex.getMessage());
}
}
}
private void sendBatch(Campaign campaign) {
int remainingToday = remainingToday(campaign);
if (remainingToday <= 0) {
return;
}
List<CampaignRecipient> batch = recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(
campaign.getId(), RecipientStatus.QUEUED, Limit.of(Math.min(BATCH_SIZE, remainingToday)));
if (batch.isEmpty()) {
campaign.setStatus(CampaignStatus.COMPLETED);
campaignRepository.save(campaign);
return;
}
SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null;
SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null;
// Bez skonfigurowanej skrzynki (albo bramki SMS) kolejka czeka - inaczej caly outbox
// spalilby sie na bledach, a kampanii nie da sie wyslac drugi raz.
if (!senderConfigured(smtp, sms)) {
log.warn("Kampania {}: brak konfiguracji kanalu {} - wysylka czeka.", campaign.getId(), campaign.getChannel());
return;
}
int sent = 0;
for (CampaignRecipient recipient : batch) {
if (stillOptedOut(recipient)) {
recipient.setStatus(RecipientStatus.OPTED_OUT);
recipientRepository.save(recipient);
continue;
}
SendResult result = campaign.getChannel() == Channel.EMAIL
? emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), emailBody(campaign, recipient))
: smsSender.send(sms, recipient.getAddress(), smsBody(campaign, recipient));
if (result.ok()) {
recipient.setStatus(RecipientStatus.SENT);
recipient.setSentAt(Instant.now());
recipient.setProviderMessageId(result.providerMessageId());
recipient.setError(null);
sent++;
} else {
recipient.setStatus(RecipientStatus.FAILED);
recipient.setError(trim(result.error()));
}
recipientRepository.save(recipient);
}
campaign.setSentToday(todaysCount(campaign) + sent);
campaign.setSentTodayDate(LocalDate.now());
if (!recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(1)).isEmpty()) {
campaignRepository.save(campaign);
return;
}
campaign.setStatus(CampaignStatus.COMPLETED);
campaignRepository.save(campaign);
}
static boolean senderConfigured(SmtpConfig smtp, SmsGatewayConfig sms) {
if (smtp != null) {
return smtp.isEnabled() && smtp.getHost() != null && !smtp.getHost().isBlank();
}
return sms != null && sms.isEnabled() && sms.getEndpointUrl() != null && !sms.getEndpointUrl().isBlank();
}
/** Ile jeszcze mozna dzis wyslac w tej kampanii - licznik zeruje sie z nowym dniem. */
private int remainingToday(Campaign campaign) {
int limit = campaign.getDailyLimit() == null || campaign.getDailyLimit() <= 0 ? Integer.MAX_VALUE : campaign.getDailyLimit();
return limit - todaysCount(campaign);
}
private int todaysCount(Campaign campaign) {
boolean isToday = LocalDate.now().equals(campaign.getSentTodayDate());
return isToday && campaign.getSentToday() != null ? campaign.getSentToday() : 0;
}
/** Zgoda mogla zostac cofnieta po zbudowaniu outboxu - sprawdzamy ja tuz przed wyslaniem. */
private boolean stillOptedOut(CampaignRecipient recipient) {
Lead lead = recipient.getLeadId() == null ? null : leadRepository.findById(recipient.getLeadId()).orElse(null);
if (lead == null) {
return false;
}
return recipient.getChannel() == Channel.EMAIL
? !lead.isEmailConsent() || lead.isEmailOptOut()
: !lead.isSmsConsent() || lead.isSmsOptOut();
}
/** Kazda wiadomosc marketingowa musi dac sie wypisac jednym kliknieciem. */
private String unsubscribeLink(CampaignRecipient recipient) {
String channel = recipient.getChannel() == Channel.EMAIL ? "email" : "sms";
return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(recipient.getLeadId(), channel);
}
private String emailBody(Campaign campaign, CampaignRecipient recipient) {
String body = campaign.getBody() == null ? "" : campaign.getBody();
String greeting = recipient.getName() == null || recipient.getName().isBlank() ? ""
: "<p>Cześć " + HtmlUtils.htmlEscape(recipient.getName().trim().split("\\s+")[0]) + ",</p>";
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
String footer = link == null ? ""
: "<hr style=\"border:0;border-top:1px solid #dde5ee;margin:20px 0\">"
+ "<p style=\"color:#8793a3;font-size:12px\">Otrzymujesz tę wiadomość, bo zgodziłeś(-aś) się na"
+ " informacje marketingowe od serwisu Polska Lokalnie. "
+ "<a href=\"" + link + "\" style=\"color:#12a764\">Zrezygnuj z tych wiadomości</a>.</p>";
return "<!doctype html><html lang=\"pl\"><body style=\"margin:0;background:#f5f7fa\">"
+ "<div style=\"max-width:560px;margin:0 auto;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#24344b\">"
+ greeting + body + footer + "</div></body></html>";
}
private String smsBody(Campaign campaign, CampaignRecipient recipient) {
String body = campaign.getBody() == null ? "" : campaign.getBody().trim();
String link = recipient.getLeadId() == null ? null : unsubscribeLink(recipient);
return link == null ? body : body + " Rezygnacja: " + link;
}
private static String trim(String value) {
if (value == null) {
return null;
}
return value.length() > 240 ? value.substring(0, 240) : value;
}
}
@@ -85,8 +85,8 @@ public class CampaignService {
/**
* Uruchamia kampanie: buduje outbox (jesli jeszcze nie istnieje) i ustawia status. Kampania
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING - workera
* wysylajacego dodajemy w kolejnej fazie.
* z data w przyszlosci przechodzi w SCHEDULED, w przeciwnym razie od razu w SENDING.
* Wysylke z outboxu obsluguje {@link CampaignSender}.
*/
@Transactional
public CampaignResponse schedule(Long id) {
@@ -147,7 +147,15 @@ public class DataSeeder implements CommandLineRunner {
String email = adminEmail.trim().toLowerCase();
AppUser existing = userRepository.findByEmailIgnoreCase(email).orElse(null);
// Bez hasla w konfiguracji nie zakladamy ani nie resetujemy konta - na produkcji nie ma wartosci
// domyslnej, a jawne haslo z repozytorium (Admin123!) nie moze trafic na serwer przez pomylke.
boolean passwordMissing = adminPassword == null || adminPassword.isBlank();
if (existing == null) {
if (passwordMissing) {
log.error("Brak APP_ADMIN_PASSWORD - konto administratora {} NIE zostalo utworzone.", email);
return;
}
AppUser admin = new AppUser();
admin.setEmail(email);
admin.setFullName(adminName);
@@ -164,6 +172,10 @@ public class DataSeeder implements CommandLineRunner {
if (!adminResetPassword) {
return;
}
if (passwordMissing) {
log.error("APP_ADMIN_RESET_PASSWORD=true, ale brak APP_ADMIN_PASSWORD - haslo konta {} bez zmian.", email);
return;
}
// Awaryjne przywrocenie dostepu: haslo, rola i aktywacja wracaja do stanu z konfiguracji.
// Blokada konta tez znika - inaczej reset hasla nie przywrocilby logowania.
@@ -1,132 +0,0 @@
package pl.polskalokalnie.i18n;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.user.PreferredLanguage;
@Service
public class RuntimeTranslationService {
private static final int MAX_TEXTS_PER_REQUEST = 180;
private static final int MAX_TEXT_LENGTH = 400;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, String> cache = new ConcurrentHashMap<>();
public Map<String, String> translateBatch(PreferredLanguage language, List<String> texts) {
if (texts == null || texts.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak tekstów do tłumaczenia");
}
if (texts.size() > MAX_TEXTS_PER_REQUEST) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Zbyt wiele tekstów do tłumaczenia w jednym żądaniu");
}
if (language == PreferredLanguage.PL) {
Map<String, String> passthrough = new LinkedHashMap<>();
texts.forEach((text) -> passthrough.put(text, text));
return passthrough;
}
String targetCode = toGoogleLang(language);
Map<String, String> results = new LinkedHashMap<>();
List<String> toTranslate = new ArrayList<>();
for (String text : texts) {
String safeText = text != null ? text : "";
if (safeText.length() > MAX_TEXT_LENGTH) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Jeden z tekstów jest zbyt długi do tłumaczenia");
}
String cacheKey = targetCode + "|" + safeText;
String cached = cache.get(cacheKey);
if (cached != null) {
results.put(safeText, cached);
} else {
toTranslate.add(safeText);
}
}
for (String text : toTranslate) {
String translated = translateSingle(text, targetCode);
cache.put(targetCode + "|" + text, translated);
results.put(text, translated);
}
return results;
}
private String translateSingle(String source, String targetCode) {
if (source.isBlank()) {
return source;
}
String encoded = URLEncoder.encode(source, StandardCharsets.UTF_8);
String url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=pl&tl="
+ targetCode + "&dt=t&q=" + encoded;
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.GET()
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.header("User-Agent", "PolskaLokalnie/1.0")
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Usługa tłumaczeń jest chwilowo niedostępna");
}
return parseTranslatedText(response.body(), source);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
} catch (IOException ex) {
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
}
}
private String parseTranslatedText(String payload, String fallback) throws IOException {
JsonNode root = objectMapper.readTree(payload);
JsonNode topSegments = root.path(0);
if (!topSegments.isArray() || topSegments.isEmpty()) {
return fallback;
}
StringBuilder translated = new StringBuilder();
for (JsonNode segment : topSegments) {
JsonNode part = segment.path(0);
if (part.isTextual()) {
translated.append(part.asText());
}
}
String value = translated.toString();
return value.isBlank() ? fallback : value;
}
private String toGoogleLang(PreferredLanguage language) {
return switch (language) {
case EN -> "en";
case UK -> "uk";
case DE -> "de";
case PL -> "pl";
};
}
}
@@ -1,12 +0,0 @@
package pl.polskalokalnie.i18n;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import pl.polskalokalnie.user.PreferredLanguage;
public record TranslateRequest(
@NotNull PreferredLanguage language,
@NotEmpty List<String> texts
) {
}
@@ -1,10 +0,0 @@
package pl.polskalokalnie.i18n;
import java.util.Map;
import pl.polskalokalnie.user.PreferredLanguage;
public record TranslateResponse(
PreferredLanguage language,
Map<String, String> translations
) {
}
@@ -1,31 +0,0 @@
package pl.polskalokalnie.i18n;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/i18n")
public class TranslationController {
private final RuntimeTranslationService runtimeTranslationService;
public TranslationController(RuntimeTranslationService runtimeTranslationService) {
this.runtimeTranslationService = runtimeTranslationService;
}
@PostMapping("/translate")
public TranslateResponse translate(@Valid @RequestBody TranslateRequest request) {
List<String> normalizedTexts = request.texts().stream()
.map(text -> text == null ? "" : text)
.distinct()
.collect(Collectors.toList());
Map<String, String> translations = runtimeTranslationService.translateBatch(request.language(), normalizedTexts);
return new TranslateResponse(request.language(), translations);
}
}
@@ -67,13 +67,17 @@ public class Lead {
@Column(name = "tag", length = 60)
private List<String> tags = new ArrayList<>();
// Zgoda marketingowa musi byc wyrazna i uprzednia (art. 4 pkt 11 i art. 7 RODO oraz Prawo
// komunikacji elektronicznej), wiec domyslnie jej NIE MA. Wlacza ja sam uzytkownik
// w ustawieniach konta (MarketingConsentController) albo administrator przy imporcie leada,
// majac podstawe zebrana poza serwisem.
@Column(nullable = false)
@ColumnDefault("true")
private boolean emailConsent = true;
@ColumnDefault("false")
private boolean emailConsent = false;
@Column(nullable = false)
@ColumnDefault("true")
private boolean smsConsent = true;
@ColumnDefault("false")
private boolean smsConsent = false;
// Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje.
@Column(nullable = false)
@@ -231,7 +231,11 @@ public class LeadService {
lead.setPhone(phone);
}
}
lead.setName(trimToNull(request.fullName()));
// Formularz jest publiczny i nie potwierdza, ze wysylajacy jest wlascicielem adresu - wpisanie
// cudzego e-maila nie moze nadpisac nazwy istniejacego kontaktu.
if (lead.getId() == null || lead.getName() == null) {
lead.setName(trimToNull(request.fullName()));
}
List<String> tags = new ArrayList<>(lead.getTags());
tags.add(ADVERTISING_TAG);
@@ -239,9 +243,12 @@ public class LeadService {
lead.setTags(cleanTags(tags));
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu.
lead.setEmailConsent(consent);
lead.setSmsConsent(consent);
// telefoniczny. Tylko dla NOWEGO kontaktu: formularz jest anonimowy, wiec wpisujac cudzy
// e-mail lub telefon daloby sie komus przywrocic cofnieta zgode (albo ja odebrac).
if (lead.getId() == null) {
lead.setEmailConsent(consent);
lead.setSmsConsent(consent);
}
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
leadRepository.save(lead);
}
@@ -1,5 +1,6 @@
package pl.polskalokalnie.listing;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
@@ -21,9 +22,11 @@ import org.springframework.web.bind.annotation.RestController;
public class ListingController {
private final ListingService listingService;
private final ListingViewGuard viewGuard;
public ListingController(ListingService listingService) {
public ListingController(ListingService listingService, ListingViewGuard viewGuard) {
this.listingService = listingService;
this.viewGuard = viewGuard;
}
/**
@@ -40,6 +43,12 @@ public class ListingController {
return listingService.search(city, offerType, propertyType, directOnly);
}
// Inne oferty sprzedajacego - publiczne jak same ogloszenia (GET /api/listings/**).
@GetMapping("/{id}/seller-listings")
public List<ListingResponse> sellerListings(@PathVariable Long id) {
return listingService.sellerListings(id);
}
@GetMapping("/mine")
public List<ListingResponse> mine(Authentication authentication) {
return listingService.findMine(authentication.getName());
@@ -50,12 +59,16 @@ public class ListingController {
public ListingDetailResponse getById(
@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean incrementView,
Authentication authentication
Authentication authentication,
HttpServletRequest request
) {
String viewerEmail = authentication == null ? null : authentication.getName();
boolean viewerIsAdmin = authentication != null && authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
return listingService.getById(id, incrementView, viewerEmail, viewerIsAdmin);
// Adres klienta ustawia nginx (X-Real-IP) - backend nie jest osiagalny z pominieciem nginx.
String viewer = request.getHeader("X-Real-IP") != null ? request.getHeader("X-Real-IP") : request.getRemoteAddr();
boolean countView = incrementView && viewGuard.shouldCount(id, viewer);
return listingService.getById(id, countView, viewerEmail, viewerIsAdmin);
}
@PostMapping
@@ -69,7 +82,7 @@ public class ListingController {
.body(response);
}
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; status bez zmian).
// Edycja wlasnego ogloszenia (pelna aktualizacja pol; istotna zmiana tresci wraca do moderacji).
@PutMapping("/{id}")
public ListingDetailResponse update(
@PathVariable Long id,
@@ -72,18 +72,22 @@ public record ListingDetailResponse(
private static final double METERS_PER_DEGREE = 111_320d;
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar) {
return from(listing, ownerId, ownerAvatar, null, null, null, null, false);
return from(listing, ownerId, ownerAvatar, null, null, null, null, false, false);
}
/**
* Gdy {@code masked} jest true (ogloszenie z ukryta lokalizacja ogladane przez kogos innego niz
* wlasciciel i administracja), z odpowiedzi znikaja ulica oraz numer budynku, a wspolrzedne sa
* przesuwane w obrebie obszaru. Przesuniecie liczymy z identyfikatora ogloszenia, wiec dla danej
* oferty jest zawsze takie samo - inaczej kolo skakaloby po mapie przy kazdym odswiezeniu.
* zastepowane srodkiem kratki siatki, w ktorej lezy adres - dla danej oferty zawsze tym samym,
* wiec kolo nie skacze po mapie przy kazdym odswiezeniu.
*
* Gdy {@code publicView} jest true (oglada ktos inny niz wlasciciel i administracja), znika adres
* e-mail konta wlasciciela - zarowno ownerEmail, jak i contactEmail, ktory formularz wypelnia
* adresem konta. E-mail jest loginem do serwisu, a publiczny kontakt to telefon i wiadomosci.
*/
public static ListingDetailResponse from(PropertyListing listing, Long ownerId, String ownerAvatar,
String ownerAccountType, String ownerName, Instant ownerCreatedAt, Instant ownerLastActiveAt,
boolean masked) {
boolean masked, boolean publicView) {
String street = masked ? null : listing.getStreet();
String building = masked ? null : listing.getBuilding();
String address = masked ? approximateAddress(listing) : listing.getAddress();
@@ -111,7 +115,7 @@ public record ListingDetailResponse(
listing.getOwnership(),
listing.getContactName(),
listing.getContactPhone(),
listing.getContactEmail(),
publicView ? null : listing.getContactEmail(),
listing.getRentExtra(),
listing.getAvailableFrom(),
listing.getFurnishing(),
@@ -126,7 +130,7 @@ public record ListingDetailResponse(
List.copyOf(listing.getAmenities()),
List.copyOf(listing.getPhotos()),
listing.getVirtualTourUrl(),
listing.getOwnerEmail(),
publicView ? null : listing.getOwnerEmail(),
ownerId,
ownerAvatar,
ownerAccountType,
@@ -147,31 +151,33 @@ public record ListingDetailResponse(
return district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity();
}
private static Double blurLat(PropertyListing listing) {
// Zamiast przesuniecia liczonego z (jawnego) identyfikatora ogloszenia - ktore dalo sie odwrocic
// i odzyskac dokladny adres - punkt jest przyciagany do srodka kratki siatki. Z odpowiedzi wiadomo
// tylko, w ktorej kratce lezy adres. Kratka ma bok rowny promieniowi kola, wiec najdalszy jej rog
// (~0,71 promienia od srodka) zawsze miesci sie w rysowanym obszarze, a punkt nie skacze po mapie.
private static final double GRID_CELL_M = APPROXIMATE_RADIUS_M;
static Double blurLat(PropertyListing listing) {
if (listing.getLat() == null) {
return null;
}
return listing.getLat() + (offsetMeters(listing) * Math.cos(offsetAngle(listing))) / METERS_PER_DEGREE;
return snapToCellCenter(listing.getLat(), GRID_CELL_M / METERS_PER_DEGREE);
}
private static Double blurLng(PropertyListing listing) {
static Double blurLng(PropertyListing listing) {
if (listing.getLng() == null || listing.getLat() == null) {
return null;
}
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(listing.getLat()));
if (parallel == 0) {
// Szerokosc kratki w stopniach dlugosci liczona dla srodka kratki szerokosci, a nie dla
// dokladnej szerokosci adresu - inaczej sam rozmiar kratki zdradzalby dokladne lat.
double parallel = METERS_PER_DEGREE * Math.cos(Math.toRadians(blurLat(listing)));
if (parallel <= 0) {
return listing.getLng();
}
return listing.getLng() + (offsetMeters(listing) * Math.sin(offsetAngle(listing))) / parallel;
return snapToCellCenter(listing.getLng(), GRID_CELL_M / parallel);
}
// Kierunek i dlugosc przesuniecia sa stale dla ogloszenia, a punkt zostaje w polowie promienia,
// wiec rzeczywisty adres zawsze miesci sie wewnatrz rysowanego kola.
private static double offsetAngle(PropertyListing listing) {
return Math.toRadians((listing.getId() * 137L) % 360);
}
private static double offsetMeters(PropertyListing listing) {
return (listing.getId() * 53L) % (long) (APPROXIMATE_RADIUS_M / 2);
private static double snapToCellCenter(double value, double cellDegrees) {
return (Math.floor(value / cellDegrees) + 0.5) * cellDegrees;
}
}
@@ -61,7 +61,7 @@ public class ListingFavoriteController {
// Ogloszenie skasowane albo odrzucone po zapisaniu po prostu znika z listy.
return listingRepository.findAllById(listingIds).stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
.map(ListingResponse::publicFrom)
.toList();
}
@@ -6,4 +6,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
List<PropertyListing> findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(String ownerEmail, ListingStatus status);
List<PropertyListing> findByOwnerEmailIgnoreCase(String ownerEmail);
}
@@ -30,15 +30,20 @@ public record ListingResponse(
Instant promotedUntil,
Instant createdAt
) {
/** Pelna wersja - dla panelu wlasciciela ("moje ogloszenia") i administracji. */
public static ListingResponse from(PropertyListing listing) {
return from(listing, false);
return build(listing, false, listing.getOwnerEmail());
}
/**
* @param masked pokaz sama okolice zamiast adresu - dla ogloszen z ukryta dokladna lokalizacja
* ogladanych spoza panelu wlasciciela i administracji.
* Wersja publiczna (wyszukiwarka, ulubione, inne oferty sprzedajacego): sama okolica zamiast
* adresu dla ogloszen z ukryta lokalizacja i bez e-maila wlasciciela - to login do serwisu.
*/
public static ListingResponse from(PropertyListing listing, boolean masked) {
public static ListingResponse publicFrom(PropertyListing listing) {
return build(listing, listing.isApproximateLocation(), null);
}
private static ListingResponse build(PropertyListing listing, boolean masked, String ownerEmail) {
String district = listing.getDistrict();
String address = masked
? (district == null || district.isBlank() ? listing.getCity() : district + ", " + listing.getCity())
@@ -60,7 +65,7 @@ public record ListingResponse(
listing.getMarket(),
listing.getYearBuilt(),
listing.getCoverPhoto(),
listing.getOwnerEmail(),
ownerEmail,
listing.getStatus(),
listing.getViewsCount(),
listing.getPromotedUntil(),
@@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.search.SavedSearchNotifier;
import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@@ -28,15 +29,17 @@ public class ListingService {
private final NotificationService notificationService;
private final UserRepository userRepository;
private final UserSettingsService userSettingsService;
private final SavedSearchNotifier savedSearchNotifier;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService, UserRepository userRepository,
UserSettingsService userSettingsService) {
UserSettingsService userSettingsService, SavedSearchNotifier savedSearchNotifier) {
this.listingRepository = listingRepository;
this.textModerationService = textModerationService;
this.notificationService = notificationService;
this.userRepository = userRepository;
this.userSettingsService = userSettingsService;
this.savedSearchNotifier = savedSearchNotifier;
}
// Konto wlasciciela ogloszenia - null, gdy ogloszenie nie ma odpowiednika wsrod uzytkownikow.
@@ -52,21 +55,22 @@ public class ListingService {
* Ogloszenia demonstracyjne nie maja konta w serwisie - wtedy oba pola sa puste.
*/
private ListingDetailResponse detailOf(PropertyListing listing) {
return detailOf(listing, false);
return detailOf(listing, false, false);
}
/**
* @param masked ukryj dokladny adres (ulica, numer, wspolrzedne) - dla ogloszen, ktorych
* wlasciciel wybral publikowanie samej przyblizonej lokalizacji.
* @param publicView odpowiedz dla kogos innego niz wlasciciel i administracja - bez adresu e-mail konta.
*/
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked) {
private ListingDetailResponse detailOf(PropertyListing listing, boolean masked, boolean publicView) {
AppUser owner = ownerOf(listing);
if (owner == null) {
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked);
return ListingDetailResponse.from(listing, null, null, null, null, null, null, masked, publicView);
}
String ownerAccountType = owner.getAccountType() == null ? null : owner.getAccountType().name();
return ListingDetailResponse.from(listing, owner.getId(), userSettingsService.avatarFor(owner.getId()),
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked);
ownerAccountType, owner.getFullName(), owner.getCreatedAt(), owner.getLastActiveAt(), masked, publicView);
}
/**
@@ -97,7 +101,7 @@ public class ListingService {
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.filter(listing -> !directOnly || isDirectOffer(listing))
.sorted(promotedFirst())
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
.map(ListingResponse::publicFrom)
.toList();
}
@@ -134,8 +138,29 @@ public class ListingService {
listing = listingRepository.save(listing);
}
// Wlasciciel i administracja widza pelny adres - reszta tylko przyblizona lokalizacje.
return detailOf(listing, listing.isApproximateLocation() && !isOwner && !viewerIsAdmin);
// Wlasciciel i administracja widza pelny adres i e-mail konta. Reszta - tylko przyblizona
// lokalizacje i bez e-maila (to login do serwisu; kontakt idzie przez wiadomosci i telefon).
boolean publicView = !isOwner && !viewerIsAdmin;
return detailOf(listing, listing.isApproximateLocation() && publicView, publicView);
}
/**
* Inne opublikowane oferty wlasciciela danego ogloszenia. Porownanie po e-mailu odbywa sie
* na serwerze, zeby adres wlasciciela nie musial trafiac do przegladarki.
*/
public List<ListingResponse> sellerListings(Long listingId) {
PropertyListing listing = listingRepository.findById(listingId).orElse(null);
if (listing == null || listing.getStatus() != ListingStatus.APPROVED
|| listing.getOwnerEmail() == null || listing.getOwnerEmail().isBlank()) {
return List.of();
}
return listingRepository
.findByOwnerEmailIgnoreCaseAndStatusOrderByCreatedAtDesc(listing.getOwnerEmail(), ListingStatus.APPROVED)
.stream()
.filter(other -> !other.getId().equals(listingId))
.limit(6)
.map(ListingResponse::publicFrom)
.toList();
}
public List<ListingResponse> findMine(String ownerEmail) {
@@ -161,7 +186,11 @@ public class ListingService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic");
}
listing.setStatus(status);
return ListingResponse.from(listingRepository.save(listing));
PropertyListing saved = listingRepository.save(listing);
if (status == ListingStatus.APPROVED) {
savedSearchNotifier.notifyMatching(saved);
}
return ListingResponse.from(saved);
}
@Transactional
@@ -194,8 +223,8 @@ public class ListingService {
return detailOf(saved);
}
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem, ale NIE zmienia statusu -
// zatwierdzone ogloszenie pozostaje widoczne od razu po zapisie zmian.
// Edycja wlasnego ogloszenia. Waliduje tresc filtrem; istotna zmiana tresci odsyla ogloszenie
// do ponownej weryfikacji, a drobne zmiany (cena, metraz, parametry) sa widoczne od razu.
@Transactional
public ListingDetailResponse updateOwn(Long id, String ownerEmail, ListingCreateRequest request) {
PropertyListing listing = listingRepository.findById(id)
@@ -206,13 +235,27 @@ public class ListingService {
request.title(), request.description(), request.city(), request.address(),
request.district(), request.street(), request.contactName());
List<Object> before = moderatedContent(listing);
applyRequest(listing, request);
// ownerEmail i status pozostaja bez zmian.
// Zmiana tresci widocznej dla kupujacych (tytul, opis, zdjecia, kontakt, link spaceru) wraca do
// weryfikacji - inaczej zatwierdzone ogloszenie mozna bylo podmienic na dowolne, np. oszustwo.
// Dotyczy tez wstrzymanego, bo wlasciciel sam je wznawia. Cena, metraz itp. zmieniaja sie od razu.
boolean visible = listing.getStatus() == ListingStatus.APPROVED || listing.getStatus() == ListingStatus.PAUSED;
if (visible && !before.equals(moderatedContent(listing))) {
listing.setStatus(ListingStatus.PENDING);
}
PropertyListing saved = listingRepository.save(listing);
return detailOf(saved);
}
// Pola, ktorych zmiana wymaga ponownej akceptacji administracji.
static List<Object> moderatedContent(PropertyListing listing) {
return java.util.Arrays.asList(listing.getTitle(), listing.getDescription(), List.copyOf(listing.getPhotos()),
listing.getContactName(), listing.getContactPhone(), listing.getContactEmail(),
listing.getVirtualTourUrl());
}
// Wspolne mapowanie pol requestu na encje (uzywane przez create i updateOwn).
private void applyRequest(PropertyListing listing, ListingCreateRequest request) {
listing.setTitle(request.title().trim());
@@ -273,6 +316,11 @@ public class ListingService {
listing.setStatus(status);
PropertyListing saved = listingRepository.save(listing);
notifyOwnerStatusChange(saved, status);
if (status == ListingStatus.APPROVED) {
// Ogloszenie wlasnie stalo sie widoczne - szukajacy z pasujacym zapisanym
// wyszukiwaniem dostaja powiadomienie na liscie w serwisie.
savedSearchNotifier.notifyMatching(saved);
}
return ListingResponse.from(saved);
}
@@ -306,6 +354,17 @@ public class ListingService {
listingRepository.deleteById(id);
}
/**
* Usuniecie wszystkich ogloszen konta - przy usuwaniu konta. Bez tego opublikowane ogloszenia
* (z imieniem i telefonem wlasciciela) zostawaly w serwisie bez wlasciciela.
*/
@Transactional
public void deleteAllOfOwner(String ownerEmail) {
if (ownerEmail != null && !ownerEmail.isBlank()) {
listingRepository.deleteAll(listingRepository.findByOwnerEmailIgnoreCase(ownerEmail));
}
}
private static String trimToNull(String value) {
if (value == null) {
return null;
@@ -327,11 +386,20 @@ public class ListingService {
return cleaned;
}
private static List<String> limitPhotos(List<String> photos) {
// Zdjecia przyjmujemy wylacznie jako data URL obrazu rastrowego. Inny typ (np. text/html,
// image/svg+xml) serwowany potem z domeny serwisu pozwalalby uruchomic skrypt u oglądającego.
private static final java.util.regex.Pattern PHOTO_DATA_URL =
java.util.regex.Pattern.compile("^data:image/(jpeg|png|webp);base64,[A-Za-z0-9+/=\\s]+$");
static List<String> limitPhotos(List<String> photos) {
List<String> result = new ArrayList<>();
if (photos != null) {
for (String photo : photos) {
if (photo != null && !photo.isBlank()) {
if (!PHOTO_DATA_URL.matcher(photo).matches()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Nieprawidłowy format zdjęcia - dozwolone są obrazy JPEG, PNG i WebP");
}
result.add(photo);
if (result.size() >= MAX_PHOTOS) {
break;
@@ -0,0 +1,39 @@
package pl.polskalokalnie.listing;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
/**
* Jedno wyswietlenie ogloszenia na adres IP na godzine. Licznik wyswietlen wplywa na ranking
* dzielnic i to, co widza sprzedajacy - bez tego dalo sie go dowolnie nabijac odswiezaniem strony.
* Pamiec jest ograniczona: po przekroczeniu limitu wpisow stare sa czyszczone.
*/
@Component
public class ListingViewGuard {
private static final Duration WINDOW = Duration.ofHours(1);
private static final int MAX_ENTRIES = 100_000;
private final Map<String, Instant> lastCounted = new ConcurrentHashMap<>();
/** true, gdy to wyswietlenie ma sie wliczyc do licznika. */
public boolean shouldCount(Long listingId, String viewer) {
Instant now = Instant.now();
if (lastCounted.size() > MAX_ENTRIES) {
lastCounted.values().removeIf(at -> at.isBefore(now.minus(WINDOW)));
if (lastCounted.size() > MAX_ENTRIES) {
lastCounted.clear();
}
}
String key = listingId + "|" + (viewer == null ? "" : viewer);
Instant previous = lastCounted.get(key);
if (previous != null && previous.isAfter(now.minus(WINDOW))) {
return false;
}
lastCounted.put(key, now);
return true;
}
}
@@ -0,0 +1,85 @@
package pl.polskalokalnie.market;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Comparator;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* Stopa rynku pienieznego dla Polski z Eurostatu (zbior irt_st_m, seria IRT_M3 - stawka 3-miesieczna,
* czyli odpowiednik WIBOR 3M). Dane miesieczne, bez klucza dostepu.
*
* DLACZEGO NIE NBP: NBP publikuje srednie oprocentowanie nowych kredytow mieszkaniowych, ale pliki
* lezą pod adresami zmieniajacymi sie co miesiac, a strony nbp.pl sa chronione przed automatycznym
* pobieraniem (odpowiadaja strona bota zamiast trescia). Dziala tylko static.nbp.pl z plikiem cen
* mieszkan, ktory juz pobieramy w module historii cen.
*/
@Component
public class EurostatRateClient {
static final String URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/irt_st_m"
+ "?format=JSON&geo=PL&int_rt=IRT_M3&lastTimePeriod=6";
/** Wartosc stopy w procentach wraz z miesiacem, ktorego dotyczy ("2026-08"). */
public record Rate(BigDecimal percent, String period) {
}
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private final ObjectMapper mapper = new ObjectMapper();
public Rate latestThreeMonthRate() throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(URL))
.timeout(Duration.ofSeconds(30))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) {
throw new IOException("Eurostat odpowiedzial " + response.statusCode());
}
return parseLatest(mapper.readTree(response.body()));
}
/**
* Odpowiedz w formacie JSON-stat: wartosci leza w mapie "value" pod indeksem plaskim, a wymiar
* czasu ma wlasne indeksy. Przy jednej serii (geo=PL, int_rt=IRT_M3) indeks czasu jest zarazem
* indeksem wartosci, ale zamiast na tym polegac dobieramy wartosc po indeksie z wymiaru "time".
*/
static Rate parseLatest(JsonNode root) throws IOException {
JsonNode values = root.path("value");
JsonNode periods = root.path("dimension").path("time").path("category").path("index");
if (values.isMissingNode() || periods.isMissingNode() || periods.isEmpty()) {
throw new IOException("Eurostat: odpowiedz bez danych");
}
// Najpozniejszy miesiac z dostepna wartoscia - ostatnie okresy bywaja jeszcze puste.
Map.Entry<String, JsonNode> latest = null;
for (var it = periods.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
if (values.has(String.valueOf(entry.getValue().asInt())) && (latest == null
|| Comparator.<String>naturalOrder().compare(entry.getKey(), latest.getKey()) > 0)) {
latest = entry;
}
}
if (latest == null) {
throw new IOException("Eurostat: brak wartosci dla ktoregokolwiek miesiaca");
}
BigDecimal percent = values.path(String.valueOf(latest.getValue().asInt())).decimalValue();
if (percent.signum() <= 0) {
throw new IOException("Eurostat: nieprawidlowa wartosc stopy " + percent);
}
return new Rate(percent, latest.getKey());
}
}
@@ -0,0 +1,92 @@
package pl.polskalokalnie.market;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
/**
* Pojedynczy wskaznik rynkowy pobierany z zewnatrz (przecietne wynagrodzenie, stopa rynku
* pieniężnego). Trzymamy ostatnia znana wartosc razem z okresem, ktorego dotyczy, i momentem
* pobrania - dzieki temu kalkulator moze pokazac zrodlo i date, a nieudany import niczego nie psuje.
*/
@Entity
@Table(name = "market_indicators")
public class MarketIndicator {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "indicator_key", nullable = false, unique = true, length = 40)
private String key;
@Column(nullable = false, precision = 12, scale = 4)
private BigDecimal value;
/** Okres, ktorego dotyczy wartosc, w formie podanej przez zrodlo: "2025" albo "2026-08". */
@Column(name = "as_of", nullable = false, length = 20)
private String asOf;
/** Nazwa zrodla pokazywana uzytkownikowi, np. "GUS, Bank Danych Lokalnych". */
@Column(nullable = false, length = 120)
private String source;
@Column(name = "fetched_at", nullable = false)
private Instant fetchedAt;
protected MarketIndicator() {
}
public MarketIndicator(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
this.key = key;
this.value = value;
this.asOf = asOf;
this.source = source;
this.fetchedAt = fetchedAt;
}
public Long getId() {
return id;
}
public String getKey() {
return key;
}
public BigDecimal getValue() {
return value;
}
public void setValue(BigDecimal value) {
this.value = value;
}
public String getAsOf() {
return asOf;
}
public void setAsOf(String asOf) {
this.asOf = asOf;
}
public String getSource() {
return source;
}
public void setSource(String source) {
this.source = source;
}
public Instant getFetchedAt() {
return fetchedAt;
}
public void setFetchedAt(Instant fetchedAt) {
this.fetchedAt = fetchedAt;
}
}
@@ -0,0 +1,34 @@
package pl.polskalokalnie.market;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Wskazniki rynkowe dla kalkulatora zdolnosci - publiczne, jak sam kalkulator.
* Odpowiedz niesie tez zrodlo i okres, zeby interfejs mogl je podpisac przy liczbach.
*/
@RestController
@RequestMapping("/api/market-indicators")
public class MarketIndicatorController {
public record IndicatorResponse(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
}
private final MarketIndicatorService service;
public MarketIndicatorController(MarketIndicatorService service) {
this.service = service;
}
@GetMapping
public List<IndicatorResponse> list() {
return service.all().stream()
.map(indicator -> new IndicatorResponse(indicator.getKey(), indicator.getValue(),
indicator.getAsOf(), indicator.getSource(), indicator.getFetchedAt()))
.toList();
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.market;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MarketIndicatorRepository extends JpaRepository<MarketIndicator, Long> {
Optional<MarketIndicator> findByKey(String key);
}
@@ -0,0 +1,119 @@
package pl.polskalokalnie.market;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.polskalokalnie.pricehistory.GusBdlClient;
/**
* Wskazniki rynkowe dla kalkulatora zdolnosci kredytowej - pobierane raz w tygodniu, tak jak ceny
* transakcyjne w module historii cen. Nieudane pobranie zostawia poprzednia wartosc; gdy zadnej
* jeszcze nie ma, kalkulator korzysta z wartosci zapasowych wpisanych w interfejsie.
*/
@Service
public class MarketIndicatorService {
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (GUS). */
public static final String AVERAGE_WAGE = "average-wage-gross";
/** Stopa rynku pienieznego 3M dla Polski - odpowiednik WIBOR 3M (Eurostat). */
public static final String MONEY_MARKET_3M = "money-market-3m";
private static final Logger log = LoggerFactory.getLogger(MarketIndicatorService.class);
private final MarketIndicatorRepository repository;
private final GusBdlClient gusClient;
private final EurostatRateClient eurostatClient;
public MarketIndicatorService(MarketIndicatorRepository repository, GusBdlClient gusClient,
EurostatRateClient eurostatClient) {
this.repository = repository;
this.gusClient = gusClient;
this.eurostatClient = eurostatClient;
}
@Transactional(readOnly = true)
public List<MarketIndicator> all() {
return repository.findAll();
}
@Async
@EventListener(ApplicationReadyEvent.class)
public void refreshOnStartupIfStale() {
Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
if (isStale(AVERAGE_WAGE, weekAgo)) {
refreshAverageWage();
}
if (isStale(MONEY_MARKET_3M, weekAgo)) {
refreshMoneyMarketRate();
}
}
@Scheduled(cron = "0 25 4 * * MON", zone = "Europe/Warsaw")
public void refreshWeekly() {
refreshAverageWage();
refreshMoneyMarketRate();
}
public boolean refreshAverageWage() {
try {
GusBdlClient.AnnualValue wage = gusClient.latestAverageWage();
save(AVERAGE_WAGE, wage.value(), String.valueOf(wage.year()), "GUS, Bank Danych Lokalnych");
log.info("Wskazniki: przecietne wynagrodzenie {} zl za {}", wage.value(), wage.year());
return true;
} catch (IOException | RuntimeException ex) {
log.warn("Wskazniki: nie udalo sie pobrac wynagrodzenia z GUS ({}) - zostaje poprzednia wartosc",
ex.getMessage());
return false;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
}
}
public boolean refreshMoneyMarketRate() {
try {
EurostatRateClient.Rate rate = eurostatClient.latestThreeMonthRate();
save(MONEY_MARKET_3M, rate.percent(), rate.period(), "Eurostat, stopy rynku pieniężnego (irt_st_m)");
log.info("Wskazniki: stopa rynku pienieznego 3M {}% za {}", rate.percent(), rate.period());
return true;
} catch (IOException | RuntimeException ex) {
log.warn("Wskazniki: nie udalo sie pobrac stopy z Eurostatu ({}) - zostaje poprzednia wartosc",
ex.getMessage());
return false;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
}
}
private boolean isStale(String key, Instant threshold) {
return repository.findByKey(key).map(indicator -> indicator.getFetchedAt().isBefore(threshold)).orElse(true);
}
@Transactional
protected void save(String key, BigDecimal value, String asOf, String source) {
Optional<MarketIndicator> existing = repository.findByKey(key);
Instant now = Instant.now();
if (existing.isPresent()) {
MarketIndicator indicator = existing.get();
indicator.setValue(value);
indicator.setAsOf(asOf);
indicator.setSource(source);
indicator.setFetchedAt(now);
repository.save(indicator);
} else {
repository.save(new MarketIndicator(key, value, asOf, source, now));
}
}
}
@@ -90,6 +90,8 @@ public class MeetingService {
}
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());
@@ -179,10 +181,7 @@ public class MeetingService {
}
private void sendConfirmationEmail(AppUser recipient, AppUser other, Meeting meeting) {
boolean wantsEmail = userSettingsRepository.findByUserId(recipient.getId())
.map(UserSettings::isNotifyMessages)
.orElse(true);
if (!wantsEmail) {
if (!wantsNotifications(recipient)) {
return;
}
accountEmailService.sendMeetingConfirmed(recipient.getEmail(), recipient.getFullName(), displayName(other),
@@ -190,10 +189,21 @@ public class MeetingService {
}
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);
@@ -212,6 +222,13 @@ public class MeetingService {
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) {
@@ -261,7 +278,11 @@ public class MeetingService {
return "Konto usunięte";
}
String name = user.getFullName();
return name == null || name.isBlank() ? user.getEmail() : name;
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) {
@@ -48,19 +48,23 @@ public class ConversationMessageNotifier {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onMessageSent(MessageSentEvent event) {
try {
// Przelacznik "Wiadomości od użytkowników" w ustawieniach konta wycisza oba kanaly:
// powiadomienie w serwisie i e-mail. Sama wiadomosc i tak czeka w /wiadomosci.
boolean wantsNotification = userSettingsRepository.findByUserId(event.recipientId())
.map(UserSettings::isNotifyMessages)
.orElse(true);
if (!wantsNotification) {
return;
}
String preview = event.content().length() > 90 ? event.content().substring(0, 90) + "…" : event.content();
notificationService.create(event.recipientEmail(), "messages", "message",
"Nowa wiadomość: " + event.listingTitle(),
event.senderName() + ": " + preview,
"messages:conversation:" + event.conversationId(), null);
boolean wantsEmail = userSettingsRepository.findByUserId(event.recipientId())
.map(UserSettings::isNotifyMessages)
.orElse(true);
if (wantsEmail) {
accountEmailService.sendNewMessage(event.recipientEmail(), event.recipientName(), event.senderName(),
event.listingTitle(), event.content(), event.conversationId());
}
accountEmailService.sendNewMessage(event.recipientEmail(), event.recipientName(), event.senderName(),
event.listingTitle(), event.content(), event.conversationId());
} catch (RuntimeException ex) {
log.warn("Nie udalo sie powiadomic o wiadomosci w rozmowie {}: {}", event.conversationId(), ex.getMessage());
}
@@ -59,6 +59,12 @@ public class ConversationService {
public ConversationDetail start(AppUser me, Long listingId, String content) {
PropertyListing listing = listingRepository.findById(listingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
// Nowa rozmowa tylko o opublikowanym ogloszeniu - inaczej kolejne numery ujawnialy tytul, cene
// i sprzedajacego ogloszen oczekujacych, wstrzymanych i odrzuconych. Trwajaca rozmowa zostaje.
if (listing.getStatus() != ListingStatus.APPROVED
&& conversationRepository.findByListingIdAndBuyerId(listingId, me.getId()).isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia");
}
AppUser seller = listing.getOwnerEmail() == null ? null
: userRepository.findByEmailIgnoreCase(listing.getOwnerEmail()).orElse(null);
if (seller == null) {
@@ -174,6 +180,8 @@ public class ConversationService {
*/
@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());
@@ -264,6 +272,11 @@ public class ConversationService {
return conversation;
}
/** Blokada miedzy uczestnikami rozmowy obowiazuje tez poza wiadomosciami (np. propozycje spotkan). */
public void requireCanWrite(Conversation conversation, AppUser me) {
requireNotBlocked(me.getId(), conversation.otherParticipant(me.getId()));
}
private void requireNotBlocked(Long meId, Long otherId) {
if (userBlockRepository.existsByBlockerIdAndBlockedId(meId, otherId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Zablokowałeś tego użytkownika. Odblokuj go, aby pisać.");
@@ -16,15 +16,18 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import pl.polskalokalnie.auth.JwtService;
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final NotificationService notificationService;
private final JwtService jwtService;
public NotificationController(NotificationService notificationService) {
public NotificationController(NotificationService notificationService, JwtService jwtService) {
this.notificationService = notificationService;
this.jwtService = jwtService;
}
@GetMapping
@@ -86,6 +89,15 @@ public class NotificationController {
return created == null ? null : NotificationResponse.from(created);
}
/** Krotki token do otwarcia strumienia (EventSource przekazuje go w adresie, nie w naglowku). */
@PostMapping("/stream-token")
public StreamTokenResponse streamToken(Authentication authentication) {
return new StreamTokenResponse(jwtService.generateStreamToken(authentication.getName()));
}
public record StreamTokenResponse(String token) {
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(Authentication authentication) {
return notificationService.subscribe(authentication.getName());
@@ -8,7 +8,6 @@ import java.util.List;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
@@ -92,17 +91,18 @@ public class OgController {
if (listing == null || listing.getStatus() != ListingStatus.APPROVED || !hasImage(listing)) {
return ResponseEntity.notFound().build();
}
String cover = listing.getPhotos().get(0);
DecodedImage image = decodeDataUrl(cover);
DecodedImage image = decodeDataUrl(listing.getPhotos().get(0));
if (image == null) {
// Zdjecie zapisane jako zwykly URL (nie data URL) - odsylamy crawlera pod ten adres.
return ResponseEntity.status(HttpStatus.FOUND)
.header("Location", cover)
.build();
// Bez przekierowania pod zapisany adres - to byloby otwarte przekierowanie na dowolna strone.
return ResponseEntity.notFound().build();
}
// nosniff + sandbox: nawet gdyby w bazie siedzial spreparowany plik, przegladarka nie wykona
// go jako strony w domenie serwisu.
return ResponseEntity.ok()
.contentType(MediaType.valueOf(image.mimeType()))
.cacheControl(CacheControl.maxAge(java.time.Duration.ofHours(1)).cachePublic())
.header("X-Content-Type-Options", "nosniff")
.header("Content-Security-Policy", "default-src 'none'; sandbox")
.body(image.data());
}
@@ -111,9 +111,14 @@ public class OgController {
return photos != null && !photos.isEmpty() && photos.get(0) != null && !photos.get(0).isBlank();
}
private record DecodedImage(String mimeType, byte[] data) {}
record DecodedImage(String mimeType, byte[] data) {}
private static DecodedImage decodeDataUrl(String value) {
// Tylko obrazy rastrowe - typ MIME pochodzi od uzytkownika i nie moze decydowac o tym,
// jako co przegladarka potraktuje odpowiedz (text/html, image/svg+xml = skrypt w domenie serwisu).
private static final java.util.Set<String> ALLOWED_IMAGE_TYPES = java.util.Set.of(
MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE, "image/webp");
static DecodedImage decodeDataUrl(String value) {
if (value == null || !value.startsWith("data:")) {
return null;
}
@@ -129,6 +134,10 @@ public class OgController {
if (mime.isBlank()) {
mime = MediaType.IMAGE_JPEG_VALUE;
}
mime = mime.trim().toLowerCase(Locale.ROOT);
if (!ALLOWED_IMAGE_TYPES.contains(mime)) {
return null;
}
try {
byte[] data = Base64.getDecoder().decode(value.substring(comma + 1));
return new DecodedImage(mime, data);
@@ -0,0 +1,143 @@
package pl.polskalokalnie.payment;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* Parser notyfikacji ITN (XML przekazywany w polu {@code transactions} zakodowany base64).
*
* Dokument pochodzi z sieci, wiec parser jest twardo zabezpieczony przed XXE (wylaczone DTD
* i encje zewnetrzne) - inaczej spreparowana notyfikacja mogla by czytac pliki serwera albo
* wymuszac zadania wychodzace (SSRF).
*/
public final class AutoPayItnParser {
private AutoPayItnParser() {
}
/** Pojedyncza transakcja z notyfikacji wraz z polami wchodzacymi do podpisu. */
public record ItnTransaction(
String orderId,
String remoteId,
String amount,
String currency,
String gatewayId,
String paymentDate,
String paymentStatus,
String paymentStatusDetails
) {
public boolean success() {
return "SUCCESS".equalsIgnoreCase(paymentStatus);
}
/** Pola w kolejnosci, w jakiej wchodza do podpisu notyfikacji. */
List<String> signedValues() {
List<String> values = new ArrayList<>();
values.add(orderId);
values.add(remoteId);
values.add(amount);
values.add(currency);
values.add(gatewayId);
values.add(paymentDate);
values.add(paymentStatus);
values.add(paymentStatusDetails);
return values;
}
}
/** Zawartosc notyfikacji: identyfikator serwisu, lista transakcji i podpis calosci. */
public record Itn(String serviceId, List<ItnTransaction> transactions, String hash) {
/** Wartosci podpisu calej notyfikacji: serviceID, a potem pola kolejnych transakcji. */
public List<String> signedValues() {
List<String> values = new ArrayList<>();
values.add(serviceId);
for (ItnTransaction tx : transactions) {
values.addAll(tx.signedValues());
}
return values;
}
}
public static Itn parse(String xml) {
Document doc = parseSecurely(xml);
Element root = doc.getDocumentElement();
String serviceId = childText(root, "serviceID");
// Podpis jest bezposrednim dzieckiem korzenia (nie elementem transakcji).
String hash = childText(root, "hash");
List<ItnTransaction> transactions = new ArrayList<>();
for (Element tx : elementsByTag(root, "transaction")) {
transactions.add(new ItnTransaction(
childText(tx, "orderID"),
childText(tx, "remoteID"),
childText(tx, "amount"),
childText(tx, "currency"),
childText(tx, "gatewayID"),
childText(tx, "paymentDate"),
childText(tx, "paymentStatus"),
childText(tx, "paymentStatusDetails")));
}
if (serviceId == null || transactions.isEmpty()) {
throw invalid();
}
return new Itn(serviceId, transactions, hash);
}
private static Document parseSecurely(String xml) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory.newDocumentBuilder()
.parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
} catch (ParserConfigurationException e) {
throw new IllegalStateException("Nie mozna skonfigurowac bezpiecznego parsera XML", e);
} catch (Exception e) {
throw invalid();
}
}
private static ResponseStatusException invalid() {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
}
/** Tekst bezposredniego dziecka o danej nazwie (bez schodzenia w glab drzewa). */
private static String childText(Element parent, String tag) {
NodeList children = parent.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() == Node.ELEMENT_NODE && tag.equals(node.getNodeName())) {
String text = node.getTextContent();
return text == null || text.isBlank() ? null : text.trim();
}
}
return null;
}
private static List<Element> elementsByTag(Element root, String tag) {
List<Element> result = new ArrayList<>();
NodeList nodes = root.getElementsByTagName(tag);
for (int i = 0; i < nodes.getLength(); i++) {
result.add((Element) nodes.item(i));
}
return result;
}
}
@@ -2,23 +2,19 @@ package pl.polskalokalnie.payment;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
/**
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media).
* Integracja z bramka szybkich platnosci AutoPay (Autopay/Blue Media) - wg rozwiazania dzialajacego
* produkcyjnie w Gieldzie Komunalnej.
*
* Buduje przekierowanie na paywall (parametry + podpis Hash = SHA256 z wartosci polaczonych
* separatorem i klucza wspolpracy) oraz weryfikuje podpis w powiadomieniu ITN.
*
* UWAGA: dokladna kolejnosc parametrow i format ITN nalezy potwierdzic z aktualna dokumentacja
* AutoPay przy podlaczaniu produkcyjnych kluczy - metody podpisu sa tu wydzielone, by latwo dopasowac.
* Regula podpisu z dokumentacji: sklejane sa WARTOSCI pol (bez nazw) w kolejnosci z listy
* parametrow, rozdzielone separatorem, a na koncu doklejany jest klucz konfiguracyjny. Puste pola
* opcjonalne sa POMIJANE RAZEM Z SEPARATOREM. Przyklad z dokumentacji: ServiceID=2, OrderID=100,
* Amount=1.50, klucz=2test2 daje SHA256("2|100|1.50|2test2").
*/
@Service
public class AutoPayService {
@@ -26,66 +22,38 @@ public class AutoPayService {
private static final String PROD_URL = "https://pay.autopay.eu/payment";
private static final String SANDBOX_URL = "https://testpay.autopay.eu/payment";
/** Adres paywalla z podpisanymi parametrami transakcji. */
public String buildRedirectUrl(PaymentConfig cfg, String orderId, BigDecimal amount,
String description, String customerEmail, String returnUrl) {
String amountStr = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
// Kolejnosc parametrow ma znaczenie dla podpisu.
Map<String, String> params = new LinkedHashMap<>();
params.put("ServiceID", cfg.getServiceId());
params.put("OrderID", orderId);
params.put("Amount", amountStr);
params.put("Currency", "PLN");
params.put("Description", description == null ? "" : description);
params.put("CustomerEmail", customerEmail == null ? "" : customerEmail);
if (returnUrl != null && !returnUrl.isBlank()) {
params.put("ReturnURL", returnUrl);
}
String hash = computeHash(new ArrayList<>(params.values()), cfg.getHashSeparator(), cfg.getSecretKey());
params.put("Hash", hash);
String base = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
StringBuilder query = new StringBuilder();
for (Map.Entry<String, String> e : params.entrySet()) {
if (query.length() > 0) {
query.append('&');
}
query.append(enc(e.getKey())).append('=').append(enc(e.getValue()));
}
return base + "?" + query;
/**
* Podpisane pola formularza startu transakcji w kolejnosci wymaganej przez dokumentacje
* (ServiceID, OrderID, Amount, Description, CustomerEmail) - ta sama kolejnosc wchodzi do skrotu.
* Formularz wysyla przegladarka metoda POST, wiec podpis nie trafia do paska adresu ani historii.
*/
public AutoPayStartForm startForm(PaymentConfig cfg, String orderId, BigDecimal amount,
String description, String customerEmail) {
String amountStr = formatAmount(amount);
String hash = hash(cfg, List.of(cfg.getServiceId(), orderId, amountStr, description,
customerEmail == null ? "" : customerEmail));
String gatewayUrl = cfg.isSandbox() ? SANDBOX_URL : PROD_URL;
return new AutoPayStartForm(gatewayUrl, cfg.getServiceId(), orderId, amountStr, description,
customerEmail, hash);
}
/** Weryfikacja podpisu w powiadomieniu ITN (przelicza hash z wszystkich pol poza Hash + klucz). */
public boolean verifyItn(Map<String, String> params, PaymentConfig cfg) {
if (params == null || cfg == null || cfg.getSecretKey() == null) {
return false;
}
String provided = params.get("Hash");
if (provided == null || provided.isBlank()) {
return false;
}
List<String> values = new ArrayList<>();
for (Map.Entry<String, String> e : params.entrySet()) {
if (!"Hash".equalsIgnoreCase(e.getKey())) {
values.add(e.getValue());
}
}
String expected = computeHash(values, cfg.getHashSeparator(), cfg.getSecretKey());
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
provided.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
/** Kwota w formacie 0.00 (wymagane dokladnie dwa miejsca po przecinku). */
public static String formatAmount(BigDecimal amount) {
return amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
/** SHA-256 (hex, malymi literami) z wartosci polaczonych separatorem i doklejonego klucza. */
public String computeHash(List<String> values, String separator, String key) {
List<String> parts = new ArrayList<>(values);
parts.add(key);
String data = String.join(separator, parts);
/** SHA-256 (hex) z niepustych wartosci polaczonych separatorem i doklejonego klucza. */
public String hash(PaymentConfig cfg, List<String> values) {
StringBuilder sb = new StringBuilder();
for (String value : values) {
if (value == null || value.isEmpty()) {
continue; // puste pole opcjonalne: pomijamy bez separatora
}
sb.append(value).append(cfg.getHashSeparator());
}
sb.append(cfg.getSecretKey());
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(data.getBytes(StandardCharsets.UTF_8));
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(sb.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
@@ -97,7 +65,16 @@ public class AutoPayService {
}
}
private static String enc(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
/**
* Porownanie skrotow odporne na atak czasowy (weryfikacja podpisu ITN) - String.equals konczy
* sie przy pierwszej roznicy i zdradza liczbe zgodnych znakow.
*/
public static boolean hashMatches(String expected, String actual) {
if (expected == null || actual == null) {
return false;
}
return MessageDigest.isEqual(
expected.toLowerCase().getBytes(StandardCharsets.UTF_8),
actual.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,16 @@
package pl.polskalokalnie.payment;
/**
* Podpisane pola formularza startu transakcji. Frontend wysyla je metoda POST na {@code gatewayUrl}
* (bramka AutoPay przyjmuje {@code application/x-www-form-urlencoded}).
*/
public record AutoPayStartForm(
String gatewayUrl,
String serviceId,
String orderId,
String amount,
String description,
String customerEmail,
String hash
) {
}
@@ -0,0 +1,177 @@
package pl.polskalokalnie.pricealert;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import pl.polskalokalnie.listing.PropertyType;
/**
* Obserwowana cena: albo konkretne ogloszenie, albo srednia cena za m2 w miescie.
* Alerty zyly wczesniej w localStorage i liczyla je przegladarka, wiec dzialaly tylko przy
* otwartej stronie. Teraz sprawdza je serwer, a wynik trafia na liste powiadomien uzytkownika.
*
* <p>Z alertow NIE wychodza zadne e-maile - swiadoma decyzja, zeby nie generowac ruchu pocztowego.</p>
*/
@Entity
@Table(name = "price_alerts")
public class PriceAlert {
public enum Kind {
/** Cena jednego ogloszenia - powiadamiamy przy kazdej zmianie. */
LISTING,
/** Srednia cena za m2 w miescie - powiadamiamy przy zmianie od progu w gore lub w dol. */
LOCATION
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_email", nullable = false, length = 180)
private String userEmail;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Kind kind;
/** Etykieta pokazywana uzytkownikowi: tytul ogloszenia albo nazwa lokalizacji. */
@Column(nullable = false, length = 200)
private String label;
// --- alert ogloszenia ---
@Column(name = "listing_id")
private Long listingId;
// --- alert lokalizacji ---
@Column(length = 120)
private String city;
/** Wojewodztwo - tylko do wyswietlenia, nie filtruje ogloszen. */
@Column(length = 60)
private String region;
@Enumerated(EnumType.STRING)
@Column(name = "property_type", length = 20)
private PropertyType propertyType;
/** "", "Wtórny" albo "Pierwotny" - puste znaczy oba rynki. */
@Column(length = 20)
private String market;
/** Ostatnia zmierzona wartosc: cena ogloszenia albo srednia za m2. Null = jeszcze nie mierzono. */
@Column(name = "last_value")
private Long lastValue;
@Column(name = "last_checked_at")
private Instant lastCheckedAt;
@Column(name = "notifications_enabled", nullable = false)
private boolean notificationsEnabled = true;
@Column(name = "created_at", nullable = false)
private Instant createdAt = Instant.now();
public Long getId() {
return id;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public Kind getKind() {
return kind;
}
public void setKind(Kind kind) {
this.kind = kind;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public PropertyType getPropertyType() {
return propertyType;
}
public void setPropertyType(PropertyType propertyType) {
this.propertyType = propertyType;
}
public String getMarket() {
return market;
}
public void setMarket(String market) {
this.market = market;
}
public Long getLastValue() {
return lastValue;
}
public void setLastValue(Long lastValue) {
this.lastValue = lastValue;
}
public Instant getLastCheckedAt() {
return lastCheckedAt;
}
public void setLastCheckedAt(Instant lastCheckedAt) {
this.lastCheckedAt = lastCheckedAt;
}
public boolean isNotificationsEnabled() {
return notificationsEnabled;
}
public void setNotificationsEnabled(boolean notificationsEnabled) {
this.notificationsEnabled = notificationsEnabled;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,45 @@
package pl.polskalokalnie.pricealert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* Codzienny przeglad alertow cenowych - o 03:00, czyli po nocnym zapisie srednich cen (02:30),
* zeby porownywac swieze dane. Raz dziennie to swiadomy wybor: uzytkownik dostaje najwyzej
* jedno powiadomienie na alert dziennie, bez zasypywania listy.
*/
@Component
public class PriceAlertChecker {
private static final Logger log = LoggerFactory.getLogger(PriceAlertChecker.class);
private final PriceAlertService priceAlertService;
public PriceAlertChecker(PriceAlertService priceAlertService) {
this.priceAlertService = priceAlertService;
}
@Scheduled(cron = "0 0 3 * * *")
public void checkDaily() {
run();
}
/** Przy starcie aplikacji - dzieki temu pierwszy pomiar powstaje od razu po zalozeniu alertu. */
@Scheduled(initialDelay = 90_000, fixedDelay = Long.MAX_VALUE)
public void checkOnStartup() {
run();
}
private void run() {
try {
int notified = priceAlertService.checkAll();
if (notified > 0) {
log.info("Alerty cenowe: wyslano {} powiadomien", notified);
}
} catch (RuntimeException ex) {
log.warn("Alerty cenowe: przeglad nie powiodl sie: {}", ex.getMessage());
}
}
}
@@ -0,0 +1,60 @@
package pl.polskalokalnie.pricealert;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
/** Alerty cenowe zalogowanego uzytkownika. Powiadomienia trafiaja tylko na liste w serwisie. */
@RestController
@RequestMapping("/api/price-alerts")
public class PriceAlertController {
private final PriceAlertService priceAlertService;
public PriceAlertController(PriceAlertService priceAlertService) {
this.priceAlertService = priceAlertService;
}
@GetMapping
public List<PriceAlertResponse> list(Authentication authentication) {
return priceAlertService.list(authentication.getName());
}
/** Historia zmian z ostatnich dni - z niej powstaja liczniki "spadki/wzrosty (30 dni)". */
@GetMapping("/events")
public List<PriceAlertEventResponse> events(@RequestParam(defaultValue = "30") int days, Authentication authentication) {
return priceAlertService.events(authentication.getName(), days);
}
@PostMapping
public PriceAlertResponse create(@RequestBody PriceAlertRequest request, Authentication authentication) {
return priceAlertService.create(authentication.getName(), request);
}
public record NotificationsRequest(boolean enabled) {
}
@PutMapping("/{id}/notifications")
public PriceAlertResponse setNotifications(@PathVariable Long id, @RequestBody NotificationsRequest request,
Authentication authentication) {
return priceAlertService.setNotifications(authentication.getName(), id, request != null && request.enabled());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
priceAlertService.delete(authentication.getName(), id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,30 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
public final class PriceAlertDtos {
private PriceAlertDtos() {
}
/**
* Zalozenie alertu. Z listingId powstaje alert ogloszenia, a z miastem - alert sredniej
* ceny za m2 (propertyType: APARTMENT/HOUSE, market: "", "Wtórny" albo "Pierwotny").
*/
public record PriceAlertRequest(Long listingId, String label, String city, String region,
String propertyType, String market) {
}
/**
* kind: LISTING albo LOCATION. lastValue to ostatni pomiar (cena albo zl/m2),
* a changePct - zmiana wzgledem poprzedniego pomiaru z ostatniego zdarzenia.
*/
public record PriceAlertResponse(Long id, String kind, String label, Long listingId, String city, String region,
String propertyType, String market, Long lastValue, Instant lastCheckedAt,
boolean notificationsEnabled, Instant createdAt) {
}
public record PriceAlertEventResponse(Long id, Long alertId, String kind, String label, Long listingId,
long previousValue, long currentValue, Instant changedAt) {
}
}
@@ -0,0 +1,113 @@
package pl.polskalokalnie.pricealert;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Zarejestrowana zmiana ceny dla alertu. Z tych wpisow powstaje historia na stronie alertow
* ("spadki i wzrosty z 30 dni") oraz powiadomienie na liscie uzytkownika.
*/
@Entity
@Table(name = "price_alert_events")
public class PriceAlertEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "alert_id", nullable = false)
private Long alertId;
@Column(name = "user_email", nullable = false, length = 180)
private String userEmail;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PriceAlert.Kind kind;
@Column(nullable = false, length = 200)
private String label;
@Column(name = "listing_id")
private Long listingId;
@Column(name = "previous_value", nullable = false)
private long previousValue;
@Column(name = "current_value", nullable = false)
private long currentValue;
@Column(name = "changed_at", nullable = false)
private Instant changedAt = Instant.now();
public Long getId() {
return id;
}
public Long getAlertId() {
return alertId;
}
public void setAlertId(Long alertId) {
this.alertId = alertId;
}
public String getUserEmail() {
return userEmail;
}
public void setUserEmail(String userEmail) {
this.userEmail = userEmail;
}
public PriceAlert.Kind getKind() {
return kind;
}
public void setKind(PriceAlert.Kind kind) {
this.kind = kind;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
public Long getListingId() {
return listingId;
}
public void setListingId(Long listingId) {
this.listingId = listingId;
}
public long getPreviousValue() {
return previousValue;
}
public void setPreviousValue(long previousValue) {
this.previousValue = previousValue;
}
public long getCurrentValue() {
return currentValue;
}
public void setCurrentValue(long currentValue) {
this.currentValue = currentValue;
}
public Instant getChangedAt() {
return changedAt;
}
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PriceAlertEventRepository extends JpaRepository<PriceAlertEvent, Long> {
List<PriceAlertEvent> findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(String userEmail, Instant after);
void deleteByAlertId(Long alertId);
}
@@ -0,0 +1,14 @@
package pl.polskalokalnie.pricealert;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PriceAlertRepository extends JpaRepository<PriceAlert, Long> {
List<PriceAlert> findByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
Optional<PriceAlert> findByUserEmailIgnoreCaseAndListingId(String userEmail, Long listingId);
List<PriceAlert> findByKind(PriceAlert.Kind kind);
}
@@ -0,0 +1,269 @@
package pl.polskalokalnie.pricealert;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Locale;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertEventResponse;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertRequest;
import pl.polskalokalnie.pricealert.PriceAlertDtos.PriceAlertResponse;
import pl.polskalokalnie.pricehistory.PriceSnapshotService;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettings;
import pl.polskalokalnie.user.UserSettingsRepository;
/**
* Alerty cenowe: obserwowane ogloszenia i srednie ceny za m2 w miastach.
*
* <p>Zmiany wykrywa serwer raz dziennie, a wynik trafia wylacznie na liste powiadomien
* uzytkownika (dzwonek). Zadnych e-maili - decyzja wlasciciela serwisu, zeby nie generowac
* ruchu pocztowego.</p>
*/
@Service
public class PriceAlertService {
private static final Logger log = LoggerFactory.getLogger(PriceAlertService.class);
/**
* Prog dla alertow lokalizacji. Srednia rusza sie przy kazdym nowym ogloszeniu, wiec bez
* progu powiadomienia bylyby szumem. Cena pojedynczego ogloszenia progu nie ma - tam kazda
* zmiana jest dla obserwujacego istotna.
*/
static final double LOCATION_MIN_CHANGE_PCT = 2.0;
private final PriceAlertRepository alertRepository;
private final PriceAlertEventRepository eventRepository;
private final ListingRepository listingRepository;
private final PriceSnapshotService snapshotService;
private final NotificationService notificationService;
private final UserRepository userRepository;
private final UserSettingsRepository userSettingsRepository;
public PriceAlertService(PriceAlertRepository alertRepository,
PriceAlertEventRepository eventRepository,
ListingRepository listingRepository,
PriceSnapshotService snapshotService,
NotificationService notificationService,
UserRepository userRepository,
UserSettingsRepository userSettingsRepository) {
this.alertRepository = alertRepository;
this.eventRepository = eventRepository;
this.listingRepository = listingRepository;
this.snapshotService = snapshotService;
this.notificationService = notificationService;
this.userRepository = userRepository;
this.userSettingsRepository = userSettingsRepository;
}
/**
* Globalny przelacznik "Alerty cenowe" z ustawien konta. Wylaczony wycisza wszystkie alerty
* naraz; przelacznik przy pojedynczym alercie dziala niezaleznie i wycisza tylko jego.
* Historia zmian zapisuje sie tak czy inaczej - wyciszamy powiadomienie, nie pomiar.
*/
private boolean wantsAlertNotifications(String userEmail) {
return userRepository.findByEmailIgnoreCase(userEmail)
.flatMap(user -> userSettingsRepository.findByUserId(user.getId()))
.map(UserSettings::isNotifyPriceAlerts)
.orElse(true);
}
@Transactional(readOnly = true)
public List<PriceAlertResponse> list(String userEmail) {
return alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
.map(PriceAlertService::toResponse)
.toList();
}
@Transactional(readOnly = true)
public List<PriceAlertEventResponse> events(String userEmail, int days) {
Instant since = Instant.now().minus(Math.max(1, days), ChronoUnit.DAYS);
return eventRepository.findByUserEmailIgnoreCaseAndChangedAtAfterOrderByChangedAtDesc(userEmail, since).stream()
.map(event -> new PriceAlertEventResponse(event.getId(), event.getAlertId(), event.getKind().name(),
event.getLabel(), event.getListingId(), event.getPreviousValue(), event.getCurrentValue(),
event.getChangedAt()))
.toList();
}
@Transactional
public PriceAlertResponse create(String userEmail, PriceAlertRequest request) {
if (request == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak danych alertu");
}
PriceAlert alert = request.listingId() != null
? buildListingAlert(userEmail, request.listingId())
: buildLocationAlert(userEmail, request);
alert.setLastValue(measure(alert));
alert.setLastCheckedAt(Instant.now());
return toResponse(alertRepository.save(alert));
}
@Transactional
public PriceAlertResponse setNotifications(String userEmail, Long id, boolean enabled) {
PriceAlert alert = requireOwn(userEmail, id);
alert.setNotificationsEnabled(enabled);
return toResponse(alert);
}
@Transactional
public void delete(String userEmail, Long id) {
PriceAlert alert = requireOwn(userEmail, id);
eventRepository.deleteByAlertId(alert.getId());
alertRepository.delete(alert);
}
/**
* Przeglad wszystkich alertow. Pierwszy pomiar tylko zapamietuje punkt odniesienia -
* powiadomienie powstaje dopiero przy kolejnej, rzeczywistej zmianie.
*/
@Transactional
public int checkAll() {
int notified = 0;
for (PriceAlert alert : alertRepository.findAll()) {
try {
if (checkOne(alert)) {
notified++;
}
} catch (RuntimeException ex) {
log.warn("Alert {}: sprawdzenie nie powiodlo sie: {}", alert.getId(), ex.getMessage());
}
}
return notified;
}
private boolean checkOne(PriceAlert alert) {
Long current = measure(alert);
if (current == null) {
return false;
}
Long previous = alert.getLastValue();
alert.setLastValue(current);
alert.setLastCheckedAt(Instant.now());
if (previous == null || previous <= 0 || previous.equals(current)) {
return false;
}
double changePct = ((current - previous) * 100.0) / previous;
if (alert.getKind() == PriceAlert.Kind.LOCATION && Math.abs(changePct) < LOCATION_MIN_CHANGE_PCT) {
return false;
}
PriceAlertEvent event = new PriceAlertEvent();
event.setAlertId(alert.getId());
event.setUserEmail(alert.getUserEmail());
event.setKind(alert.getKind());
event.setLabel(alert.getLabel());
event.setListingId(alert.getListingId());
event.setPreviousValue(previous);
event.setCurrentValue(current);
eventRepository.save(event);
if (alert.isNotificationsEnabled() && wantsAlertNotifications(alert.getUserEmail())) {
notify(alert, previous, current, changePct);
}
return true;
}
private void notify(PriceAlert alert, long previous, long current, double changePct) {
boolean down = current < previous;
String direction = down ? "spadła" : "wzrosła";
String link = alert.getKind() == PriceAlert.Kind.LISTING && alert.getListingId() != null
? "listing:" + alert.getListingId()
: "priceAlerts";
String title = alert.getKind() == PriceAlert.Kind.LISTING
? "Cena " + direction + ": " + alert.getLabel()
: "Zmiana cen: " + alert.getLabel();
String body = alert.getKind() == PriceAlert.Kind.LISTING
? String.format(Locale.of("pl"), "Cena %s z %s zł na %s zł (%s%.1f%%).",
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct)
: String.format(Locale.of("pl"), "Średnia cena za m² %s z %s zł na %s zł (%s%.1f%%).",
direction, money(previous), money(current), changePct > 0 ? "+" : "", changePct);
// dedupeKey chroni przed powtorzeniem tego samego powiadomienia, gdyby przebieg poszedl dwa razy.
String dedupeKey = "price-alert:" + alert.getId() + ":" + previous + "->" + current;
notificationService.create(alert.getUserEmail(), "listing", down ? "trend-down" : "trend",
title, body, link, dedupeKey);
}
/** Aktualny pomiar: cena ogloszenia albo srednia cena za m2 w miescie. Null = nie ma czego zmierzyc. */
private Long measure(PriceAlert alert) {
if (alert.getKind() == PriceAlert.Kind.LISTING) {
return listingRepository.findById(alert.getListingId())
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.map(PropertyListing::getPrice)
.map(price -> price.longValue())
.orElse(null);
}
PriceSnapshotService.Average average = snapshotService.currentAverage(
OfferType.SALE, alert.getPropertyType(), alert.getMarket() == null ? "" : alert.getMarket(),
alert.getCity(), "");
return average == null ? null : average.avgPricePerM2();
}
private PriceAlert buildListingAlert(String userEmail, Long listingId) {
PropertyListing listing = listingRepository.findById(listingId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono ogłoszenia"));
PriceAlert existing = alertRepository.findByUserEmailIgnoreCaseAndListingId(userEmail, listingId).orElse(null);
if (existing != null) {
return existing;
}
PriceAlert alert = new PriceAlert();
alert.setUserEmail(userEmail);
alert.setKind(PriceAlert.Kind.LISTING);
alert.setListingId(listingId);
alert.setLabel(listing.getTitle());
return alert;
}
private PriceAlert buildLocationAlert(String userEmail, PriceAlertRequest request) {
if (request.city() == null || request.city().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj miasto obserwowanej lokalizacji");
}
String city = request.city().trim();
PropertyType propertyType = "HOUSE".equalsIgnoreCase(request.propertyType()) ? PropertyType.HOUSE : PropertyType.APARTMENT;
String market = request.market() == null ? "" : request.market().trim();
PriceAlert alert = alertRepository.findByUserEmailIgnoreCaseOrderByCreatedAtDesc(userEmail).stream()
.filter(item -> item.getKind() == PriceAlert.Kind.LOCATION)
.filter(item -> city.equalsIgnoreCase(item.getCity()))
.filter(item -> item.getPropertyType() == propertyType)
.filter(item -> market.equalsIgnoreCase(item.getMarket() == null ? "" : item.getMarket()))
.findFirst()
.orElseGet(PriceAlert::new);
alert.setUserEmail(userEmail);
alert.setKind(PriceAlert.Kind.LOCATION);
alert.setCity(city);
alert.setRegion(request.region() == null ? null : request.region().trim());
alert.setPropertyType(propertyType);
alert.setMarket(market);
alert.setLabel(request.label() == null || request.label().isBlank() ? city : request.label().trim());
return alert;
}
private PriceAlert requireOwn(String userEmail, Long id) {
return alertRepository.findById(id)
.filter(alert -> alert.getUserEmail().equalsIgnoreCase(userEmail))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Nie znaleziono alertu"));
}
private static String money(long value) {
return String.format(Locale.of("pl"), "%,d", value).replace(' ', ' ');
}
private static PriceAlertResponse toResponse(PriceAlert alert) {
return new PriceAlertResponse(alert.getId(), alert.getKind().name(), alert.getLabel(), alert.getListingId(),
alert.getCity(), alert.getRegion(),
alert.getPropertyType() == null ? null : alert.getPropertyType().name(),
alert.getMarket(), alert.getLastValue(), alert.getLastCheckedAt(),
alert.isNotificationsEnabled(), alert.getCreatedAt());
}
}
@@ -49,6 +49,35 @@ public class GusBdlClient {
return matchCityUnit(results, city);
}
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (temat P2497, zmienna 64428, dane roczne). */
static final long WAGE_VARIABLE_ID = 64428;
public record AnnualValue(int year, java.math.BigDecimal value) {
}
/** Najnowszy rok z wartoscia przecietnego wynagrodzenia brutto dla calej Polski. */
public AnnualValue latestAverageWage() throws IOException, InterruptedException {
JsonNode response = get("/data/by-variable/" + WAGE_VARIABLE_ID + "?format=json&unit-level=0&page-size=100");
return parseLatestAnnualValue(response);
}
static AnnualValue parseLatestAnnualValue(JsonNode response) throws IOException {
AnnualValue latest = null;
for (JsonNode result : response.path("results")) {
for (JsonNode value : result.path("values")) {
int year = value.path("year").asInt(0);
java.math.BigDecimal amount = value.path("val").decimalValue();
if (year > 0 && amount.signum() > 0 && (latest == null || year > latest.year())) {
latest = new AnnualValue(year, amount);
}
}
}
if (latest == null) {
throw new IOException("GUS BDL: brak wartosci przecietnego wynagrodzenia");
}
return latest;
}
public List<Variable> variables() throws IOException, InterruptedException {
return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results"));
}
@@ -1,56 +1,35 @@
package pl.polskalokalnie.promotion;
import java.util.Map;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/**
* Publiczny endpoint powiadomien AutoPay (ITN - Instant Transaction Notification).
* Weryfikuje podpis i oznacza zamowienie jako oplacone.
*
* UWAGA: realny ITN AutoPay przesyla dane w okreslonym formacie (XML/base64) - ten scaffold przyjmuje
* parametry form/query i weryfikuje hash; przy podlaczeniu produkcyjnym nalezy dopasowac parsowanie
* i format odpowiedzi potwierdzajacej wg aktualnej dokumentacji AutoPay.
* Bramka nie ma tokenu JWT - uwierzytelnienie nadawcy opiera sie wylacznie na podpisie liczonym
* kluczem konfiguracyjnym, weryfikowanym w {@link AutoPayItnService}. Adres ITN ustawia sie w panelu
* AutoPay: Ustawienia serwisu -> Konfiguracja techniczna serwisu -> Edytuj -> "Adres ITN"
* (pole widac dopiero w formularzu edycji): https://polskalokalnie.pl/api/payments/autopay/itn
*/
@RestController
@RequestMapping("/api/payments/autopay")
public class AutoPayItnController {
private final AutoPayService autoPayService;
private final PaymentConfigService paymentConfigService;
private final PromotionService promotionService;
private final AutoPayItnService itnService;
public AutoPayItnController(AutoPayService autoPayService,
PaymentConfigService paymentConfigService,
PromotionService promotionService) {
this.autoPayService = autoPayService;
this.paymentConfigService = paymentConfigService;
this.promotionService = promotionService;
public AutoPayItnController(AutoPayItnService itnService) {
this.itnService = itnService;
}
@PostMapping("/itn")
public ResponseEntity<String> itn(@RequestParam Map<String, String> params) {
PaymentConfig cfg = paymentConfigService.get();
if (!autoPayService.verifyItn(params, cfg)) {
return ResponseEntity.badRequest().body("ERROR: invalid hash");
}
String orderId = params.get("OrderID");
if (orderId != null && isSuccess(params)) {
promotionService.markPaidByExternalId(orderId);
}
return ResponseEntity.ok("OK");
}
private static boolean isSuccess(Map<String, String> params) {
String status = params.getOrDefault("PaymentStatus",
params.getOrDefault("Status", params.getOrDefault("paymentStatus", "SUCCESS")));
String s = status == null ? "" : status.trim().toUpperCase();
return s.equals("SUCCESS") || s.equals("CONFIRMED") || s.equals("TRUE") || s.equals("PENDING");
/** Notyfikacja o statusie transakcji. Odpowiedz: podpisany XML z CONFIRMED/NOTCONFIRMED. */
@PostMapping(value = "/itn", produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<String> itn(@RequestParam("transactions") String transactions) {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_XML)
.body(itnService.handle(transactions));
}
}
@@ -0,0 +1,177 @@
package pl.polskalokalnie.promotion;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.payment.AutoPayItnParser;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/**
* Obsluga notyfikacji ITN z AutoPay - jedyne miejsce, w ktorym zamowienie zostaje uznane za oplacone
* (wg rozwiazania dzialajacego produkcyjnie w Gieldzie Komunalnej).
*
* Kolejnosc sprawdzen (fail-closed - kazdy niespelniony warunek daje NOTCONFIRMED):
* 1. podpis calej notyfikacji liczony kluczem konfiguracyjnym (uwierzytelnia nadawce),
* 2. zgodnosc identyfikatora serwisu,
* 3. istnienie zamowienia o danym OrderID,
* 4. zgodnosc kwoty i waluty z zamowieniem (obrona przed manipulacja kwota),
* 5. idempotencja - powtorzona notyfikacja nie nalicza drugi raz.
*
* AutoPay ponawia notyfikacje az do potwierdzenia, wiec powtorki sa normalnym trybem pracy.
*/
@Service
public class AutoPayItnService {
private static final Logger log = LoggerFactory.getLogger(AutoPayItnService.class);
private static final String CONFIRMED = "CONFIRMED";
private static final String NOT_CONFIRMED = "NOTCONFIRMED";
private static final String NL = "\n";
private final PaymentConfigService paymentConfigService;
private final AutoPayService autoPayService;
private final PromotionOrderRepository orderRepository;
private final PromotionService promotionService;
public AutoPayItnService(PaymentConfigService paymentConfigService, AutoPayService autoPayService,
PromotionOrderRepository orderRepository, PromotionService promotionService) {
this.paymentConfigService = paymentConfigService;
this.autoPayService = autoPayService;
this.orderRepository = orderRepository;
this.promotionService = promotionService;
}
/**
* Przetwarza notyfikacje i buduje podpisana odpowiedz XML wymagana przez AutoPay.
*
* @param transactionsBase64 zawartosc parametru {@code transactions} (XML w base64)
*/
@Transactional
public String handle(String transactionsBase64) {
PaymentConfig cfg = paymentConfigService.get();
AutoPayItnParser.Itn itn = AutoPayItnParser.parse(decode(transactionsBase64));
boolean authentic = verifySignature(cfg, itn);
if (!authentic) {
// Bez szczegolow - to potencjalna proba podszycia sie pod bramke.
log.warn("Odrzucono notyfikacje ITN AutoPay: niepoprawny podpis lub identyfikator serwisu");
}
List<Confirmation> confirmations = new ArrayList<>();
for (AutoPayItnParser.ItnTransaction tx : itn.transactions()) {
String result = authentic ? process(tx) : NOT_CONFIRMED;
confirmations.add(new Confirmation(tx.orderId(), result));
}
return response(cfg, confirmations);
}
private boolean verifySignature(PaymentConfig cfg, AutoPayItnParser.Itn itn) {
if (!cfg.isLive() || !cfg.getServiceId().trim().equals(itn.serviceId())) {
return false;
}
String expected = autoPayService.hash(cfg, itn.signedValues());
return AutoPayService.hashMatches(expected, itn.hash());
}
private String process(AutoPayItnParser.ItnTransaction tx) {
Optional<PromotionOrder> found = orderRepository.findForUpdateByExternalId(tx.orderId());
if (found.isEmpty()) {
log.warn("Notyfikacja ITN dla nieznanego OrderID: {}", tx.orderId());
return NOT_CONFIRMED;
}
PromotionOrder order = found.get();
if (!tx.success()) {
// PENDING / FAILURE: potwierdzamy odbior (inaczej bramka ponawialaby bez konca),
// ale niczego nie naliczamy.
if ("FAILURE".equalsIgnoreCase(tx.paymentStatus()) && order.getStatus() == OrderStatus.PENDING) {
order.setStatus(OrderStatus.FAILED);
orderRepository.save(order);
}
return CONFIRMED;
}
// Idempotencja: powtorzona notyfikacja o oplaconym zamowieniu jest potwierdzana,
// ale nie nalicza drugi raz kredytow ani wyroznienia.
if (order.getStatus() == OrderStatus.PAID) {
return CONFIRMED;
}
if (!amountMatches(order.getAmount(), tx.amount()) || !"PLN".equalsIgnoreCase(tx.currency())) {
log.warn("Notyfikacja ITN z niezgodna kwota/waluta dla OrderID {}: oczekiwano {} PLN, otrzymano {} {}",
tx.orderId(), order.getAmount(), tx.amount(), tx.currency());
return NOT_CONFIRMED;
}
promotionService.applyPaid(order);
log.info("AutoPay: oplacono zamowienie promowania, OrderID {}, zamowienie {}", tx.orderId(), order.getId());
return CONFIRMED;
}
/** Porownanie kwot wartoscia liczbowa (a nie tekstem) - "49.00" i "49.0" to ta sama kwota. */
static boolean amountMatches(BigDecimal expected, String actual) {
if (expected == null || actual == null) {
return false;
}
try {
return expected.compareTo(new BigDecimal(actual)) == 0;
} catch (NumberFormatException e) {
return false;
}
}
private record Confirmation(String orderId, String result) {
}
/** Odpowiedz wymagana przez AutoPay: XML z potwierdzeniami i wlasnym podpisem. */
private String response(PaymentConfig cfg, List<Confirmation> confirmations) {
String serviceId = cfg.getServiceId() == null ? "" : cfg.getServiceId().trim();
// Podpis odpowiedzi: serviceID, a nastepnie dla kazdej transakcji orderID i confirmation.
List<String> values = new ArrayList<>();
values.add(serviceId);
for (Confirmation c : confirmations) {
values.add(c.orderId());
values.add(c.result());
}
String hash = autoPayService.hash(cfg, values);
StringBuilder entries = new StringBuilder();
for (Confirmation c : confirmations) {
entries.append(" <transactionConfirmed>").append(NL)
.append(" <orderID>").append(escape(c.orderId())).append("</orderID>").append(NL)
.append(" <confirmation>").append(c.result()).append("</confirmation>").append(NL)
.append(" </transactionConfirmed>").append(NL);
}
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" + NL
+ "<confirmationList>" + NL
+ " <serviceID>" + escape(serviceId) + "</serviceID>" + NL
+ " <transactionsConfirmations>" + NL
+ entries
+ " </transactionsConfirmations>" + NL
+ " <hash>" + hash + "</hash>" + NL
+ "</confirmationList>";
}
private static String decode(String base64) {
try {
return new String(Base64.getDecoder().decode(base64.trim()), StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowa notyfikacja ITN");
}
}
private static String escape(String value) {
return value == null ? "" : value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
}
@@ -9,6 +9,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.payment.AutoPayStartForm;
/** Publiczne (dla zalogowanych) operacje promowania: oferta planow/pakietow, platnosc, kredyty. */
@RestController
@@ -51,6 +52,11 @@ public class PromotionController {
return new OrderStatusResponse(promotionService.orderStatus(authentication.getName(), id));
}
@GetMapping("/order/{id}/autopay-form")
public AutoPayStartForm autoPayForm(@PathVariable Long id, Authentication authentication) {
return promotionService.autoPayForm(authentication.getName(), id);
}
public record CreditsResponse(int credits) {
}
@@ -1,10 +1,22 @@
package pl.polskalokalnie.promotion;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PromotionOrderRepository extends JpaRepository<PromotionOrder, Long> {
Optional<PromotionOrder> findByExternalId(String externalId);
List<PromotionOrder> findByOrderByCreatedAtDesc();
/**
* Zamowienie z blokada wiersza do konca transakcji - rownolegle notyfikacje ITN o tym samym
* zamowieniu obsluguja sie po kolei, wiec kredyty nie zostana naliczone dwa razy.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from PromotionOrder o where o.externalId = :externalId")
Optional<PromotionOrder> findForUpdateByExternalId(@Param("externalId") String externalId);
}
@@ -12,6 +12,7 @@ import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.AutoPayStartForm;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
import pl.polskalokalnie.user.AppUser;
@@ -27,7 +28,7 @@ public class PromotionService {
private final ListingRepository listingRepository;
private final PaymentConfigService paymentConfigService;
private final AutoPayService autoPayService;
private final String baseUrl;
private final boolean autoConfirmPayments;
public PromotionService(PromotionPlanRepository planRepository,
PromotionPackageRepository packageRepository,
@@ -36,7 +37,7 @@ public class PromotionService {
ListingRepository listingRepository,
PaymentConfigService paymentConfigService,
AutoPayService autoPayService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
@Value("${app.payments.auto-confirm:false}") boolean autoConfirmPayments) {
this.planRepository = planRepository;
this.packageRepository = packageRepository;
this.orderRepository = orderRepository;
@@ -44,7 +45,7 @@ public class PromotionService {
this.listingRepository = listingRepository;
this.paymentConfigService = paymentConfigService;
this.autoPayService = autoPayService;
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
this.autoConfirmPayments = autoConfirmPayments;
}
// --- Publiczne (uzytkownik) ---
@@ -63,6 +64,14 @@ public class PromotionService {
@Transactional
public CheckoutResponse checkout(String email, CheckoutRequest request) {
PaymentConfig cfg = paymentConfigService.get();
// Bez skonfigurowanej bramki nie ma komu zaplacic - zamowienie uznane "od reki" oznaczaloby
// darmowe promowanie dla kazdego. Symulacja platnosci tylko przy jawnie wlaczonym APP_PAYMENTS_AUTO_CONFIRM (lokalnie).
if (!cfg.isLive() && !autoConfirmPayments) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
}
PromotionOrder order = new PromotionOrder();
order.setUserEmail(email);
order.setKind(request.kind());
@@ -70,8 +79,7 @@ public class PromotionService {
order.setMethod(request.method() == null || request.method().isBlank() ? "AutoPay" : request.method().trim());
if (request.kind() == PromotionKind.PLAN) {
PromotionPlan plan = planRepository.findById(requireId(request.planId(), "planId"))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
PromotionPlan plan = requireActivePlan(request.planId());
PropertyListing listing = requireOwnedListing(request.listingId(), email);
order.setRefId(plan.getId());
order.setItemName("Promowanie: " + plan.getName());
@@ -79,6 +87,7 @@ public class PromotionService {
order.setListingId(listing.getId());
} else {
PromotionPackage pack = packageRepository.findById(requireId(request.packageId(), "packageId"))
.filter(PromotionPackage::isActive)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
order.setRefId(pack.getId());
order.setItemName("Pakiet promowan: " + pack.getName());
@@ -86,16 +95,43 @@ public class PromotionService {
}
order = orderRepository.save(order);
PaymentConfig cfg = paymentConfigService.get();
if (!cfg.isLive()) {
// Tryb sandbox - platnosc uznajemy od razu (demo dziala bez kluczy AutoPay).
// Lokalny tryb bez bramki (app.payments.auto-confirm) - platnosc uznajemy od razu.
applyPaid(order);
return CheckoutResponse.paid(order.getId());
}
String returnUrl = baseUrl + "/konto/moje-oferty?platnosc=" + order.getExternalId();
String url = autoPayService.buildRedirectUrl(cfg, order.getExternalId(), order.getAmount(),
order.getItemName(), email, returnUrl);
return CheckoutResponse.redirect(order.getId(), url);
// Wewnetrzna strona /platnosc/{id} pobiera podpisane pola i sama wysyla formularz POST
// do bramki - podpis nie trafia do paska adresu, historii ani naglowka Referer.
return CheckoutResponse.redirect(order.getId(), "/platnosc/" + order.getId());
}
/** Podpisany formularz startu transakcji AutoPay dla oczekujacego zamowienia wlasciciela. */
public AutoPayStartForm autoPayForm(String email, Long orderId) {
PromotionOrder order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zamowienie nie istnieje"));
if (!order.getUserEmail().equalsIgnoreCase(email)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do zamowienia");
}
if (order.getStatus() != OrderStatus.PENDING) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "To zamówienie nie oczekuje już na płatność");
}
PaymentConfig cfg = paymentConfigService.get();
if (!cfg.isLive()) {
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
"Płatności są chwilowo niedostępne. Spróbuj ponownie później.");
}
return autoPayService.startForm(cfg, order.getExternalId(), order.getAmount(),
paymentDescription(order), order.getUserEmail());
}
/**
* Tytul przelewu. Dokumentacja AutoPay ogranicza pole do 79 znakow i dopuszcza znaki
* alfanumeryczne, {@code .:-,} i spacje - budujemy go sami, bez polskich znakow z nazwy planu.
*/
static String paymentDescription(PromotionOrder order) {
return order.getKind() == PromotionKind.PLAN
? "Promowanie ogloszenia nr " + order.getListingId()
: "Pakiet promowan, zamowienie nr " + order.getId();
}
@Transactional
@@ -115,33 +151,24 @@ public class PromotionService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Pakiet nie istnieje"));
AppUser user = userRepository.findByEmailIgnoreCase(order.getUserEmail())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
user.setPromotionCredits(user.getPromotionCredits() + pack.getQuantity());
userRepository.save(user);
userRepository.addPromotionCredits(user.getId(), pack.getQuantity());
}
order.setStatus(OrderStatus.PAID);
order.setPaidAt(Instant.now());
orderRepository.save(order);
}
// Wywolywane z callbacku ITN AutoPay po zweryfikowaniu podpisu.
@Transactional
public void markPaidByExternalId(String externalId) {
orderRepository.findByExternalId(externalId).ifPresent(this::applyPaid);
}
@Transactional
public int promoteWithCredit(String email, CreditPromoteRequest request) {
AppUser user = userRepository.findByEmailIgnoreCase(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Uzytkownik nie istnieje"));
if (user.getPromotionCredits() <= 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
}
PromotionPlan plan = planRepository.findById(request.planId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
PromotionPlan plan = requireActivePlan(request.planId());
PropertyListing listing = requireOwnedListing(request.listingId(), email);
user.setPromotionCredits(user.getPromotionCredits() - 1);
userRepository.save(user);
// Sprawdzenie i zuzycie kredytu w jednym zapytaniu - rownolegle zadania nie wydadza go dwa razy.
if (userRepository.consumePromotionCredit(user.getId()) == 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Brak kredytow promowania");
}
extendPromotion(listing, plan.getDurationDays());
listingRepository.save(listing);
@@ -158,7 +185,15 @@ public class PromotionService {
order.setPaidAt(Instant.now());
orderRepository.save(order);
return user.getPromotionCredits();
return credits(email);
}
// Kupic (i wydac na nie kredyt) mozna tylko plan wystawiony w ofercie - wylaczony przez
// administracje plan (np. o starej cenie) jest niedostepny takze przy wywolaniu API wprost.
private PromotionPlan requireActivePlan(Long planId) {
return planRepository.findById(requireId(planId, "planId"))
.filter(PromotionPlan::isActive)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Plan nie istnieje"));
}
public String orderStatus(String email, Long id) {
@@ -284,7 +319,8 @@ public class PromotionService {
return id;
}
/** Losowy OrderID (32 znaki hex) - nieprzewidywalny, w limicie dlugosci pola. */
private static String generateExternalId() {
return "PL" + System.currentTimeMillis() + String.format("%04d", (int) (Math.random() * 10000));
return java.util.UUID.randomUUID().toString().replace("-", "");
}
}
@@ -19,20 +19,24 @@ public class ListingReportController {
this.userReportService = userReportService;
}
// Zglaszajacy dostaje samo potwierdzenie - pelne zgloszenie (e-mail wlasciciela, dane
// zgloszonego) widzi tylko administracja.
@PostMapping("/listings")
public ListingReportResponse createListingReport(
public ReportReceiptResponse createListingReport(
@Valid @RequestBody CreateListingReportRequest request,
Authentication authentication
) {
return listingReportService.create(request, authentication.getName());
ListingReportResponse report = listingReportService.create(request, authentication.getName());
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
}
// Zgloszenie uzytkownika z rozmowy w wiadomosciach.
@PostMapping("/users")
public UserReportResponse createUserReport(
public ReportReceiptResponse createUserReport(
@Valid @RequestBody CreateUserReportRequest request,
Authentication authentication
) {
return userReportService.create(request, authentication.getName());
UserReportResponse report = userReportService.create(request, authentication.getName());
return new ReportReceiptResponse(report.id(), report.createdAt(), report.reasonTitle(), report.status());
}
}
@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@@ -30,7 +31,10 @@ public class ListingReportService {
@Transactional
public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) {
// Zglaszac mozna tylko opublikowane ogloszenia - inaczej kolejne numery pozwalaly sprawdzac
// istnienie ogloszen oczekujacych i odrzuconych.
PropertyListing listing = listingRepository.findById(request.listingId())
.filter(found -> found.getStatus() == ListingStatus.APPROVED)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
ListingReport report = new ListingReport();
@@ -0,0 +1,10 @@
package pl.polskalokalnie.report;
import java.time.Instant;
/**
* Potwierdzenie przyjecia zgloszenia dla zglaszajacego. Celowo bez danych zgloszonego (e-mail,
* nazwa, czy ma konto) i ogloszenia - pelne zgloszenie widzi tylko administracja.
*/
public record ReportReceiptResponse(Long id, Instant createdAt, String reasonTitle, ListingReportStatus status) {
}
@@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingService;
import pl.polskalokalnie.message.Conversation;
import pl.polskalokalnie.message.ConversationRepository;
import pl.polskalokalnie.message.Message;
@@ -20,19 +21,21 @@ public class UserReportService {
// Rozmowa dolaczana do zgloszenia - limity chronia baze przed zbyt duzym wpisem.
private static final int MAX_CONVERSATION_MESSAGES = 50;
private static final int MAX_MESSAGE_LENGTH = 2000;
private final UserReportRepository userReportRepository;
private final UserRepository userRepository;
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final ListingService listingService;
public UserReportService(UserReportRepository userReportRepository, UserRepository userRepository,
ConversationRepository conversationRepository, MessageRepository messageRepository) {
ConversationRepository conversationRepository, MessageRepository messageRepository,
ListingService listingService) {
this.userReportRepository = userReportRepository;
this.userRepository = userRepository;
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.listingService = listingService;
}
@Transactional
@@ -42,7 +45,6 @@ public class UserReportService {
report.setConversationId(ReportAttachments.trimToNull(request.conversationId()));
report.setListingId(request.listingId());
report.setListingTitle(ReportAttachments.trimToNull(request.listingTitle()));
report.setConversation(cleanConversation(request.conversation()));
report.setReasonId(request.reasonId().trim());
report.setReasonTitle(request.reasonTitle().trim());
report.setDetails(ReportAttachments.trimToNull(request.details()));
@@ -53,7 +55,8 @@ public class UserReportService {
// Zgloszenie z rozmowy o ogloszeniu: zgloszonego, ogloszenie i tresc rozmowy bierzemy z bazy,
// a nie z przegladarki - nie da sie ich podrobic, a adres e-mail rozmowcy nie trafia do klienta.
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje.
// W pozostalych przypadkach konto wiazemy tylko wtedy, gdy naprawde istnieje, a tresci rozmowy
// przyslanej przez przegladarke nie zapisujemy - administracja wzielaby ja za dowod.
AppUser reporter = userRepository.findByEmailIgnoreCase(reporterEmail).orElse(null);
Conversation conversation = reporterConversation(request.conversationId(), reporter);
AppUser reported;
@@ -103,6 +106,7 @@ public class UserReportService {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
}
listingService.deleteAllOfOwner(reported.getEmail());
userRepository.delete(reported);
report.setReportedUserDeleted(true);
@@ -115,34 +119,6 @@ public class UserReportService {
return UserReportResponse.from(userReportRepository.save(report), null, findReported(report.getReporterEmail()));
}
// Zapisujemy koncowke rozmowy - to ostatnie wiadomosci sa istotne dla zgloszenia.
private static List<UserReportMessage> cleanConversation(List<UserReportMessagePayload> payloads) {
if (payloads == null || payloads.isEmpty()) {
return List.of();
}
List<UserReportMessagePayload> tail = payloads.size() > MAX_CONVERSATION_MESSAGES
? payloads.subList(payloads.size() - MAX_CONVERSATION_MESSAGES, payloads.size())
: payloads;
List<UserReportMessage> cleaned = new ArrayList<>();
for (UserReportMessagePayload payload : tail) {
if (payload == null) {
continue;
}
String content = payload.content() == null ? "" : payload.content().trim();
if (content.length() > MAX_MESSAGE_LENGTH) {
content = content.substring(0, MAX_MESSAGE_LENGTH);
}
UserReportMessage message = new UserReportMessage();
message.setFromReporter(payload.fromReporter());
message.setContent(content);
message.setSentAt(payload.sentAt());
message.setHasPhoto(payload.hasPhoto());
cleaned.add(message);
}
return cleaned;
}
// Rozmowa o ogloszeniu, w ktorej zglaszajacy bierze udzial; null dla innych identyfikatorow watku.
private Conversation reporterConversation(String conversationId, AppUser reporter) {
if (reporter == null || conversationId == null || !conversationId.trim().matches("\\d{1,18}")) {
@@ -0,0 +1,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(',', ' ');
}
}
@@ -23,8 +23,11 @@ public class MessageComposer {
/** Tresc HTML e-maila z personalizacja i stopka rezygnacji. */
public String composeEmail(String rawBody, String recipientName, Long leadId) {
String personalized = personalize(rawBody, recipientName);
String html = looksLikeHtml(personalized) ? personalized : textToHtml(personalized);
// O formacie decyduje tresc kampanii, a nie podstawione imie. Imie pochodzi m.in. z publicznego
// formularza, wiec do tresci HTML trafia escapowane - inaczej wstrzyknelo by link do maila.
String html = looksLikeHtml(rawBody)
? personalize(rawBody, recipientName == null ? null : escapeHtml(recipientName))
: textToHtml(personalize(rawBody, recipientName));
String link = unsubscribeLink(leadId, "email");
String footer = "<hr style=\"margin-top:24px;border:none;border-top:1px solid #e2e8f0\">"
+ "<p style=\"font-size:12px;color:#94a3b8;line-height:1.5\">"
@@ -64,6 +67,7 @@ public class MessageComposer {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;");
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -2,9 +2,11 @@ package pl.polskalokalnie.unsubscribe;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.HtmlUtils;
/**
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
@@ -20,17 +22,40 @@ public class UnsubscribeController {
this.unsubscribeService = unsubscribeService;
}
/**
* Otwarcie linku tylko pyta o potwierdzenie. Skanery poczty (antywirusy, podglad linkow) same
* otwieraja linki z maili - gdyby GET od razu wypisywal, ludzie tracili by subskrypcje bez klikniecia.
*/
@GetMapping(produces = MediaType.TEXT_HTML_VALUE)
public String unsubscribe(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
String kanal = "sms".equals(result.channel()) ? "SMS" : "e-mail";
return page("Rezygnacja przyjęta",
"Twój adres" + (result.maskedContact().isEmpty() ? "" : " (" + result.maskedContact() + ")")
+ " został wypisany z wiadomości marketingowych (" + kanal + "). "
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.");
public String confirm(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.preview(token);
String form = "<form method=\"post\" action=\"/api/unsubscribe?token=" + HtmlUtils.htmlEscape(token) + "\">"
+ "<button type=\"submit\">Wypisz mnie</button></form>";
return page("Rezygnacja z wiadomości",
"Czy wypisać " + contact(result) + " z wiadomości marketingowych (" + channel(result) + ")?", form);
}
private static String page(String title, String message) {
/**
* Wlasciwa rezygnacja. Token w adresie, wiec ten sam POST obsluguje tez przycisk "Wypisz sie"
* programow pocztowych (List-Unsubscribe-Post, RFC 8058).
*/
@PostMapping(produces = MediaType.TEXT_HTML_VALUE)
public String unsubscribe(@RequestParam("token") String token) {
UnsubscribeService.Result result = unsubscribeService.unsubscribe(token);
return page("Rezygnacja przyjęta",
"Twój adres " + contact(result) + " został wypisany z wiadomości marketingowych (" + channel(result) + "). "
+ "Nie będziemy już wysyłać Ci kampanii tym kanałem.", "");
}
private static String contact(UnsubscribeService.Result result) {
return result.maskedContact().isEmpty() ? "" : "(" + HtmlUtils.htmlEscape(result.maskedContact()) + ")";
}
private static String channel(UnsubscribeService.Result result) {
return "sms".equals(result.channel()) ? "SMS" : "e-mail";
}
private static String page(String title, String message, String action) {
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + title + "</title>"
@@ -38,7 +63,10 @@ public class UnsubscribeController {
+ "display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}"
+ ".card{background:#fff;max-width:460px;padding:36px 32px;border-radius:16px;"
+ "box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}"
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}</style>"
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p></div></body></html>";
+ "h1{font-size:20px;color:#0f172a;margin:0 0 12px}p{color:#475569;line-height:1.6;margin:0}"
+ "button{margin-top:20px;padding:12px 22px;border:0;border-radius:10px;background:#12a764;color:#fff;"
+ "font-size:15px;font-weight:600;cursor:pointer}</style>"
+ "</head><body><div class=\"card\"><h1>" + title + "</h1><p>" + message + "</p>" + action
+ "</div></body></html>";
}
}
@@ -41,28 +41,17 @@ public class UnsubscribeService {
return encodedPayload + "." + sign(encodedPayload);
}
/** Sprawdza link bez zmieniania czegokolwiek - do strony z pytaniem o potwierdzenie rezygnacji. */
public Result preview(String token) {
Target target = parse(token);
return new Result(target.channel(), maskContact(target.lead(), target.channel()));
}
@Transactional
public Result unsubscribe(String token) {
if (token == null || !token.contains(".")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
int dot = token.lastIndexOf('.');
String encodedPayload = token.substring(0, dot);
String signature = token.substring(dot + 1);
if (!sign(encodedPayload).equals(signature)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
}
String payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
String[] parts = payload.split(":", 2);
if (parts.length != 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
Long leadId = parseLeadId(parts[0]);
String channel = normalizeChannel(parts[1]);
Lead lead = leadRepository.findById(leadId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
Target target = parse(token);
Lead lead = target.lead();
String channel = target.channel();
if ("sms".equals(channel)) {
lead.setSmsOptOut(true);
@@ -76,6 +65,38 @@ public class UnsubscribeService {
return new Result(channel, maskContact(lead, channel));
}
private record Target(Lead lead, String channel) {
}
private Target parse(String token) {
if (token == null || !token.contains(".")) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
int dot = token.lastIndexOf('.');
String encodedPayload = token.substring(0, dot);
String signature = token.substring(dot + 1);
// Porownanie w stalym czasie - String.equals konczy sie na pierwszej roznicy i zdradza,
// ile poczatkowych znakow podpisu sie zgadza.
if (!java.security.MessageDigest.isEqual(sign(encodedPayload).getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8))) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Link rezygnacji jest nieprawidłowy lub wygasł");
}
String payload;
try {
payload = new String(URL_DECODER.decode(encodedPayload), StandardCharsets.UTF_8);
} catch (IllegalArgumentException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
String[] parts = payload.split(":", 2);
if (parts.length != 2) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
Lead lead = leadRepository.findById(parseLeadId(parts[0]))
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kontakt nie istnieje"));
return new Target(lead, normalizeChannel(parts[1]));
}
private String sign(String data) {
try {
Mac mac = Mac.getInstance(HMAC_ALGO);
@@ -0,0 +1,76 @@
package pl.polskalokalnie.user;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Usuniecie danych osobowych konta (art. 17 RODO). Dotad kasowany byl tylko wiersz app_users,
* ustawienia i ogloszenia - reszta zostawala w bazie jako sieroty: wiadomosci, powiadomienia,
* alerty, spotkania, tokeny, kody SMS z numerem telefonu i lead marketingowy.
*
* <p>Zostaja wylacznie dane, ktorych usuniecie byloby sprzeczne z innym obowiazkiem:
* zamowienia promowania (dokumentacja rozliczeniowa), zgloszenia ogloszen i uzytkownikow
* oraz korespondencja z obsluga (obrona przed roszczeniami i obowiazki moderacyjne).
* Te kategorie maja wlasne okresy przechowywania opisane w Polityce prywatnosci.</p>
*
* <p>Schemat nie ma kluczy obcych z kaskada (ddl-auto=update), wiec kasujemy jawnie,
* tabela po tabeli - dzieki temu widac wprost, co znika.</p>
*/
@Service
public class AccountErasureService {
private static final Logger log = LoggerFactory.getLogger(AccountErasureService.class);
private final JdbcTemplate jdbcTemplate;
public AccountErasureService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void eraseFor(Long userId, String email) {
if (userId == null || email == null || email.isBlank()) {
return;
}
int removed = 0;
// Dane powiazane z identyfikatorem konta.
removed += update("DELETE FROM messages WHERE sender_id = ? OR recipient_id = ?", userId, userId);
removed += update("DELETE FROM conversations WHERE buyer_id = ? OR seller_id = ?", userId, userId);
removed += update("DELETE FROM user_blocks WHERE blocker_id = ? OR blocked_id = ?", userId, userId);
removed += update("DELETE FROM meetings WHERE proposer_id = ? OR counterpart_id = ?", userId, userId);
// Dane powiazane z adresem e-mail.
removed += update("DELETE FROM listing_favorites WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM saved_searches WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM price_alert_events WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM price_alerts WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM notifications WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM auth_tokens WHERE lower(user_email) = lower(?)", email);
removed += update("DELETE FROM phone_otps WHERE lower(user_email) = lower(?)", email);
// Baza marketingowa: lead, jego tagi i pozycje w kolejkach kampanii.
removed += update("DELETE FROM campaign_recipients WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email);
removed += update("DELETE FROM lead_tags WHERE lead_id IN (SELECT id FROM leads WHERE lower(email) = lower(?))", email);
removed += update("DELETE FROM leads WHERE lower(email) = lower(?)", email);
// Poradniki uzytkownika znikaja razem z kontem - tak samo jak jego ogloszenia.
removed += update("DELETE FROM article_attachments WHERE article_id IN (SELECT id FROM articles WHERE lower(author_email) = lower(?))", email);
removed += update("DELETE FROM articles WHERE lower(author_email) = lower(?)", email);
log.info("Usunieto {} rekordow powiazanych z kontem id={}", removed, userId);
}
private int update(String sql, Object... args) {
try {
return jdbcTemplate.update(sql, args);
} catch (RuntimeException ex) {
// Brak tabeli w starszej bazie nie moze zablokowac usuniecia konta.
log.warn("Nie udalo sie wykonac czyszczenia: {} ({})", sql, ex.getMessage());
return 0;
}
}
}
@@ -10,7 +10,6 @@ import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
import org.hibernate.annotations.ColumnDefault;
@Entity
@@ -52,6 +51,9 @@ public class AppUser {
@Column(length = 30)
private String phone;
// Adres dotyczy wylacznie kont firmowych (siedziba przedsiebiorcy - dana jawna z CEIDG/KRS).
// Konto prywatne go nie ma: do kontaktu wystarcza e-mail i telefon, a adres nieruchomosci
// stoi przy ogloszeniu. Minimalizacja danych, art. 5 ust. 1 lit. c RODO.
@Column(length = 255)
private String address;
@@ -68,8 +70,6 @@ public class AppUser {
@Column(length = 20)
private String nip;
private LocalDate birthDate;
// Saldo kredytow promowania (uniwersalny token: 1 kredyt = 1 promowanie dowolnego planu).
@Column(nullable = false)
@ColumnDefault("0")
@@ -95,10 +95,21 @@ public class AppUser {
// odstep miedzy zmianami; null oznacza, ze haslo nie bylo zmieniane od zalozenia konta.
private Instant passwordChangedAt;
// Tokeny sesji wydane przed tym momentem sa odrzucane - ustawiane przy zmianie i resecie hasla,
// zeby przejeta sesja nie przetrwala zmiany hasla. null = wszystkie niewygasle tokeny wazne.
private Instant sessionsValidFrom;
// Ostatnia aktywnosc uzytkownika (logowanie oraz kazde wejscie do aplikacji odswiezajace /auth/me).
// Na karcie sprzedajacego pokazujemy z tego "ostatnio aktywny/a"; null = jeszcze nie odnotowano.
private Instant lastActiveAt;
/**
* Moment akceptacji Regulaminu i Polityki prywatnosci przy rejestracji. Zapisujemy go,
* zeby mozna bylo wykazac, kiedy uzytkownik zaakceptowal dokumenty (art. 7 ust. 1 RODO).
*/
@Column(name = "terms_accepted_at")
private Instant termsAcceptedAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
@@ -209,14 +220,6 @@ public class AppUser {
this.nip = nip;
}
public LocalDate getBirthDate() {
return birthDate;
}
public void setBirthDate(LocalDate birthDate) {
this.birthDate = birthDate;
}
public int getPromotionCredits() {
return promotionCredits;
}
@@ -261,6 +264,23 @@ public class AppUser {
this.passwordChangedAt = passwordChangedAt;
}
public Instant getSessionsValidFrom() {
return sessionsValidFrom;
}
/** Uniewaznia wszystkie dotychczasowe sesje. JWT ma dokladnosc do sekundy - stad obciecie. */
public void revokeSessions() {
this.sessionsValidFrom = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
}
public Instant getTermsAcceptedAt() {
return termsAcceptedAt;
}
public void setTermsAcceptedAt(Instant termsAcceptedAt) {
this.termsAcceptedAt = termsAcceptedAt;
}
public Instant getLastActiveAt() {
return lastActiveAt;
}
@@ -2,6 +2,9 @@ package pl.polskalokalnie.user;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface UserRepository extends JpaRepository<AppUser, Long> {
@@ -16,4 +19,17 @@ public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findFirstByRole(Role role);
long countByRole(Role role);
/**
* Zuzycie jednego kredytu promowania jednym zapytaniem z warunkiem - dwa rownolegle zadania
* nie wydadza tego samego kredytu dwa razy. Zwraca 0, gdy kredytow juz nie ma.
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update AppUser u set u.promotionCredits = u.promotionCredits - 1 where u.id = :id and u.promotionCredits > 0")
int consumePromotionCredit(@Param("id") Long id);
/** Dopisanie kredytow bez odczytu i zapisu calego konta (nie nadpisze rownoleglego zuzycia). */
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update AppUser u set u.promotionCredits = u.promotionCredits + :amount where u.id = :id")
int addPromotionCredits(@Param("id") Long id, @Param("amount") int amount);
}
@@ -110,9 +110,13 @@ public class UserSettings {
@ColumnDefault("true")
private boolean notifyMessages = true;
@Column(nullable = false)
@ColumnDefault("false")
private boolean notifyProductNews = false;
/**
* Lokalizacje ostatnio sprawdzone w Historii cen (JSON, maks. kilka pozycji). Lista jest
* na koncie, a nie w przegladarce, wiec porownanie cen wyglada tak samo na kazdym urzadzeniu.
* To zwykla historia przegladania - nie ma z nia zwiazanych zadnych powiadomien.
*/
@Column(name = "price_history_markets", columnDefinition = "text")
private String priceHistoryMarkets;
// --- Preferencje wyszukiwania (podpowiadane przy filtrowaniu ofert) ---
@@ -302,18 +306,18 @@ public class UserSettings {
this.notifyMessages = notifyMessages;
}
public boolean isNotifyProductNews() {
return notifyProductNews;
}
public void setNotifyProductNews(boolean notifyProductNews) {
this.notifyProductNews = notifyProductNews;
}
public String getSearchLocations() {
return searchLocations;
}
public String getPriceHistoryMarkets() {
return priceHistoryMarkets;
}
public void setPriceHistoryMarkets(String priceHistoryMarkets) {
this.priceHistoryMarkets = priceHistoryMarkets;
}
public void setSearchLocations(String searchLocations) {
this.searchLocations = searchLocations;
}
@@ -24,7 +24,7 @@ public record UserSettingsRequest(
Boolean notifySavedSearches,
Boolean notifyPriceAlerts,
Boolean notifyMessages,
Boolean notifyProductNews,
String priceHistoryMarkets,
String searchLocations,
String searchPropertyType,
Integer searchBudgetMax,
@@ -21,7 +21,7 @@ public record UserSettingsResponse(
boolean notifySavedSearches,
boolean notifyPriceAlerts,
boolean notifyMessages,
boolean notifyProductNews,
String priceHistoryMarkets,
String searchLocations,
String searchPropertyType,
Integer searchBudgetMax,
@@ -51,7 +51,7 @@ public record UserSettingsResponse(
settings.isNotifySavedSearches(),
settings.isNotifyPriceAlerts(),
settings.isNotifyMessages(),
settings.isNotifyProductNews(),
settings.getPriceHistoryMarkets(),
settings.getSearchLocations(),
settings.getSearchPropertyType(),
settings.getSearchBudgetMax(),
@@ -132,8 +132,9 @@ public class UserSettingsService {
if (request.notifyMessages() != null) {
settings.setNotifyMessages(request.notifyMessages());
}
if (request.notifyProductNews() != null) {
settings.setNotifyProductNews(request.notifyProductNews());
if (request.priceHistoryMarkets() != null) {
String markets = request.priceHistoryMarkets().trim();
settings.setPriceHistoryMarkets(markets.isEmpty() ? null : markets);
}
if (request.searchLocations() != null) {
String locations = request.searchLocations().trim();
@@ -25,6 +25,10 @@ app:
# W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa.
secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min}
expiration-seconds: ${APP_JWT_EXPIRATION_SECONDS:86400}
payments:
# true = bez skonfigurowanej bramki AutoPay zakup promowania jest od razu uznawany za oplacony.
# Tylko do pracy lokalnej - na produkcji oznaczaloby darmowe promowanie dla kazdego.
auto-confirm: ${APP_PAYMENTS_AUTO_CONFIRM:false}
admin:
email: ${APP_ADMIN_EMAIL:admin@softspm.com}
password: ${APP_ADMIN_PASSWORD:Admin123!}
@@ -0,0 +1,80 @@
package pl.polskalokalnie;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
/** Publiczne endpointy - regresje z audytu bezpieczenstwa 2026-09-23. */
@SpringBootTest
@AutoConfigureMockMvc
class PublicEndpointsSecurityTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private LeadRepository leadRepository;
@Autowired
private UnsubscribeService unsubscribeService;
@Test
void privateListingListsRequireLogin() throws Exception {
// Regresja: publiczna regula GET /api/listings/** przepuszczala je anonimowo (blad 500).
mockMvc.perform(get("/api/listings/favorites")).andExpect(status().isForbidden());
mockMvc.perform(get("/api/listings/mine/saves")).andExpect(status().isForbidden());
}
@Test
void phoneCodeResendDoesNotRevealWhetherAccountExists() throws Exception {
// Regresja: nieznany adres dawal 404 "Uzytkownik nie istnieje".
mockMvc.perform(post("/api/auth/resend-phone-otp")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"nikogo-takiego@example.com\"}"))
.andExpect(status().isNoContent());
}
@Test
void registrationRequiresEightCharacterPassword() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"email\":\"nowy@example.com\",\"password\":\"abc123\",\"fullName\":\"Jan Nowak\"}"))
.andExpect(status().isBadRequest());
}
@Test
void openingUnsubscribeLinkOnlyAsksForConfirmation() throws Exception {
// Regresja: sam GET (np. skaner linkow w poczcie) wypisywal z mailingu.
Lead lead = new Lead();
lead.setEmail("subskrybent@example.com");
lead.setEmailConsent(true);
lead = leadRepository.save(lead);
String token = unsubscribeService.generateToken(lead.getId(), "email");
mockMvc.perform(get("/api/unsubscribe").param("token", token))
.andExpect(status().isOk())
.andExpect(content().string(containsString("method=\"post\"")));
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isFalse();
mockMvc.perform(post("/api/unsubscribe").param("token", token))
.andExpect(status().isOk())
.andExpect(content().string(containsString("Rezygnacja przyjęta")));
org.assertj.core.api.Assertions.assertThat(leadRepository.findById(lead.getId()).orElseThrow().isEmailOptOut()).isTrue();
}
@Test
void tamperedUnsubscribeTokenIsRejected() throws Exception {
mockMvc.perform(post("/api/unsubscribe").param("token", "MTpbZW1haWw.nieprawidlowy-podpis"))
.andExpect(status().isBadRequest());
}
}
@@ -0,0 +1,22 @@
package pl.polskalokalnie.auth;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class AccountEmailServiceTest {
@Test
void greetingEscapesNameFromRegistrationForm() {
// Regresja: rejestracja na cudzy e-mail z linkiem w polu imienia dawala prawdziwy mail
// aktywacyjny z serwisu z linkiem atakujacego.
String html = AccountEmailService.greeting("<a/href=https://evil.example>Kliknij</a>");
assertThat(html).doesNotContain("<a").contains("&lt;a/href=");
}
@Test
void greetingUsesFirstName() {
assertThat(AccountEmailService.greeting("Jan Kowalski")).isEqualTo("<p>Cześć Jan,</p>");
}
}
@@ -0,0 +1,103 @@
package pl.polskalokalnie.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.context.SecurityContextHolder;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
class JwtAuthFilterTest {
private AppUser user() {
AppUser user = new AppUser();
user.setEmail("user@example.com");
user.setRole(Role.USER);
return user;
}
private final JwtService jwtService = new JwtService("test-secret-key-for-junit-context-32chars-min", 3600);
private final UserRepository userRepository = mock(UserRepository.class);
private final JwtAuthFilter filter = new JwtAuthFilter(jwtService, userRepository);
/** Czy zadanie przeszlo przez filtr jako uwierzytelnione. */
private boolean authenticated(MockHttpServletRequest request) throws Exception {
AppUser user = user();
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(user));
boolean[] result = new boolean[1];
filter.doFilter(request, new MockHttpServletResponse(),
(req, res) -> result[0] = SecurityContextHolder.getContext().getAuthentication() != null);
SecurityContextHolder.clearContext();
return result[0];
}
private MockHttpServletRequest get(String uri) {
return new MockHttpServletRequest("GET", uri);
}
@Test
void sessionTokenWorksOnlyInHeader() throws Exception {
String session = jwtService.generateToken(user());
MockHttpServletRequest header = get("/api/auth/me");
header.addHeader("Authorization", "Bearer " + session);
assertThat(authenticated(header)).isTrue();
// Regresja: token sesji w adresie (?access_token=) trafial do logow serwera.
MockHttpServletRequest inUrl = get("/api/auth/me");
inUrl.setParameter("access_token", session);
assertThat(authenticated(inUrl)).isFalse();
MockHttpServletRequest asTicket = get(JwtAuthFilter.STREAM_PATH);
asTicket.setParameter("ticket", session);
assertThat(authenticated(asTicket)).isFalse();
}
@Test
void streamTokenWorksOnlyForNotificationStream() throws Exception {
String ticket = jwtService.generateStreamToken("user@example.com");
MockHttpServletRequest stream = get(JwtAuthFilter.STREAM_PATH);
stream.setParameter("ticket", ticket);
assertThat(authenticated(stream)).isTrue();
MockHttpServletRequest otherPath = get("/api/auth/me");
otherPath.setParameter("ticket", ticket);
assertThat(authenticated(otherPath)).isFalse();
MockHttpServletRequest asSession = get("/api/auth/me");
asSession.addHeader("Authorization", "Bearer " + ticket);
assertThat(authenticated(asSession)).isFalse();
}
@Test
void activeUserWithFreshTokenIsAccepted() {
assertThat(JwtAuthFilter.isSessionValid(user(), Instant.now())).isTrue();
}
@Test
void blockedUserLosesAccessImmediately() {
// Regresja: blokada dzialala tylko przy logowaniu - wydany token dzialal jeszcze do 24 h.
AppUser user = user();
user.setBlocked(true);
assertThat(JwtAuthFilter.isSessionValid(user, Instant.now())).isFalse();
}
@Test
void tokenIssuedBeforePasswordChangeIsRejected() {
AppUser user = user();
Instant oldToken = Instant.now().minusSeconds(60);
user.revokeSessions();
assertThat(JwtAuthFilter.isSessionValid(user, oldToken)).isFalse();
// Token wydany w tej samej sekundzie co zmiana (nowa sesja z odpowiedzi) jest wazny.
assertThat(JwtAuthFilter.isSessionValid(user, user.getSessionsValidFrom())).isTrue();
}
}
@@ -0,0 +1,140 @@
package pl.polskalokalnie.campaign;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Limit;
import pl.polskalokalnie.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmtpConfig;
import pl.polskalokalnie.send.EmailSender;
import pl.polskalokalnie.send.SendResult;
import pl.polskalokalnie.send.SmsSender;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
class CampaignSenderTest {
private CampaignRepository campaignRepository;
private CampaignRecipientRepository recipientRepository;
private LeadRepository leadRepository;
private EmailSender emailSender;
private CampaignSender sender;
private Campaign campaign;
@BeforeEach
void setUp() {
campaignRepository = mock(CampaignRepository.class);
recipientRepository = mock(CampaignRecipientRepository.class);
leadRepository = mock(LeadRepository.class);
emailSender = mock(EmailSender.class);
MailConfigService mailConfigService = mock(MailConfigService.class);
UnsubscribeService unsubscribeService = mock(UnsubscribeService.class);
SmtpConfig smtp = new SmtpConfig();
smtp.setEnabled(true);
smtp.setHost("smtp.example.com");
when(mailConfigService.getSmtp()).thenReturn(smtp);
when(unsubscribeService.generateToken(any(), anyString())).thenReturn("token");
when(emailSender.send(any(), anyString(), any(), anyString())).thenReturn(new SendResult(true, null, null));
campaign = new Campaign();
campaign.setChannel(Channel.EMAIL);
campaign.setSubject("Nowe oferty");
campaign.setBody("<p>Treść</p>");
campaign.setDailyLimit(200);
campaign.setStatus(CampaignStatus.SENDING);
when(campaignRepository.findByStatus(CampaignStatus.SCHEDULED)).thenReturn(List.of());
when(campaignRepository.findByStatus(CampaignStatus.SENDING)).thenReturn(List.of(campaign));
sender = new CampaignSender(campaignRepository, recipientRepository, leadRepository, mailConfigService,
emailSender, mock(SmsSender.class), unsubscribeService, "https://polskalokalnie.pl");
}
private CampaignRecipient recipient(long leadId, String address) {
CampaignRecipient recipient = new CampaignRecipient();
recipient.setLeadId(leadId);
recipient.setChannel(Channel.EMAIL);
recipient.setAddress(address);
recipient.setStatus(RecipientStatus.QUEUED);
return recipient;
}
private Lead lead(boolean consent, boolean optOut) {
Lead lead = new Lead();
lead.setEmailConsent(consent);
lead.setEmailOptOut(optOut);
return lead;
}
private void queue(List<CampaignRecipient> batch) {
when(recipientRepository.findByCampaignIdAndStatusOrderByIdAsc(any(), eq(RecipientStatus.QUEUED), any(Limit.class)))
.thenReturn(batch, List.of());
}
@Test
void sendsQueuedRecipientAndClosesCampaign() {
CampaignRecipient recipient = recipient(1L, "kupujacy@example.com");
queue(List.of(recipient));
when(leadRepository.findById(1L)).thenReturn(Optional.of(lead(true, false)));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.SENT);
assertThat(recipient.getSentAt()).isNotNull();
// Pusta kolejka po przebiegu = kampania rozliczona.
assertThat(campaign.getStatus()).isEqualTo(CampaignStatus.COMPLETED);
assertThat(campaign.getSentToday()).isEqualTo(1);
}
@Test
void skipsRecipientWhoWithdrewConsentAfterBuildingOutbox() {
CampaignRecipient recipient = recipient(2L, "rezygnacja@example.com");
queue(List.of(recipient));
when(leadRepository.findById(2L)).thenReturn(Optional.of(lead(true, true)));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.OPTED_OUT);
verify(emailSender, never()).send(any(), anyString(), any(), anyString());
}
@Test
void marksFailureWithoutLosingTheRest() {
CampaignRecipient recipient = recipient(3L, "blad@example.com");
queue(List.of(recipient));
when(leadRepository.findById(3L)).thenReturn(Optional.of(lead(true, false)));
when(emailSender.send(any(), anyString(), any(), anyString()))
.thenReturn(new SendResult(false, null, "Nie udało się połączyć"));
sender.processOutbox();
assertThat(recipient.getStatus()).isEqualTo(RecipientStatus.FAILED);
assertThat(recipient.getError()).contains("połączyć");
assertThat(campaign.getSentToday()).isZero();
}
@Test
void waitsWhenDailyLimitIsUsedUp() {
campaign.setDailyLimit(1);
campaign.setSentToday(1);
campaign.setSentTodayDate(java.time.LocalDate.now());
sender.processOutbox();
verify(recipientRepository, never())
.findByCampaignIdAndStatusOrderByIdAsc(any(), eq(RecipientStatus.QUEUED), any(Limit.class));
assertThat(campaign.getStatus()).isEqualTo(CampaignStatus.SENDING);
}
}
@@ -0,0 +1,54 @@
package pl.polskalokalnie.lead;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
class AdvertisingEnquiryConsentTest {
private final LeadRepository repository = mock(LeadRepository.class);
private final LeadService service = new LeadService(repository);
private AdvertisingEnquiryRequest enquiry(String name) {
return new AdvertisingEnquiryRequest(name, "Firma", "ofiara@example.com", "500600700",
"Remonty", null, true);
}
@Test
void anonymousFormDoesNotRestoreWithdrawnConsentOfExistingContact() {
// Regresja: wpisanie cudzego e-maila w publicznym formularzu przywracalo mu zgody marketingowe.
Lead existing = new Lead();
ReflectionTestUtils.setField(existing, "id", 5L);
existing.setEmail("ofiara@example.com");
existing.setName("Prawdziwe Nazwisko");
existing.setEmailConsent(false);
existing.setSmsConsent(false);
when(repository.findFirstByEmailIgnoreCase("ofiara@example.com")).thenReturn(Optional.of(existing));
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.registerAdvertisingEnquiry(enquiry("<a href=https://evil>Kliknij</a>"));
assertThat(existing.isEmailConsent()).isFalse();
assertThat(existing.isSmsConsent()).isFalse();
assertThat(existing.getName()).isEqualTo("Prawdziwe Nazwisko");
}
@Test
void newContactGetsConsentFromForm() {
when(repository.findFirstByEmailIgnoreCase(any())).thenReturn(Optional.empty());
when(repository.findFirstByPhone(any())).thenReturn(Optional.empty());
Lead[] saved = new Lead[1];
when(repository.save(any())).thenAnswer(inv -> saved[0] = inv.getArgument(0));
service.registerAdvertisingEnquiry(enquiry("Jan Kowalski"));
assertThat(saved[0].isEmailConsent()).isTrue();
assertThat(saved[0].isSmsConsent()).isTrue();
assertThat(saved[0].getName()).isEqualTo("Jan Kowalski");
}
}
@@ -0,0 +1,71 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.search.SavedSearchNotifier;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
class ListingEditModerationTest {
private static final String OWNER = "wlasciciel@example.com";
private static final String PHOTO = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
private final ListingRepository repository = mock(ListingRepository.class);
private final ListingService service = new ListingService(repository, mock(TextModerationService.class),
mock(NotificationService.class), mock(UserRepository.class), mock(UserSettingsService.class),
mock(SavedSearchNotifier.class));
private PropertyListing listing;
private ListingCreateRequest request(String title, BigDecimal price, String phone) {
return new ListingCreateRequest(title, "Opis mieszkania", OfferType.SALE, PropertyType.APARTMENT, "Kraków",
null, price, 50.0, 2, null, null, null, null, null, null, null, null, null,
"Jan", phone, OWNER, null, null, null, null, null, null, null, null,
null, null, List.of(), List.of(), List.of(PHOTO), null, false);
}
@BeforeEach
void setUp() {
listing = new PropertyListing();
listing.setOwnerEmail(OWNER);
when(repository.findById(1L)).thenReturn(Optional.of(listing));
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
// Stan po pierwszym zapisie i akceptacji przez administracje.
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("500000"), "500600700"));
listing.setStatus(ListingStatus.APPROVED);
}
@Test
void priceChangeStaysPublished() {
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("480000"), "500600700"));
assertThat(listing.getStatus()).isEqualTo(ListingStatus.APPROVED);
}
@Test
void changedContentGoesBackToModeration() {
// Regresja: zatwierdzone ogloszenie mozna bylo podmienic na dowolna tresc bez weryfikacji.
service.updateOwn(1L, OWNER, request("Okazja! Zadzwon teraz", new BigDecimal("500000"), "500600700"));
assertThat(listing.getStatus()).isEqualTo(ListingStatus.PENDING);
}
@Test
void changedPhoneOfPausedListingGoesBackToModeration() {
listing.setStatus(ListingStatus.PAUSED);
service.updateOwn(1L, OWNER, request("Mieszkanie 2 pokoje", new BigDecimal("500000"), "999888777"));
assertThat(listing.getStatus()).isEqualTo(ListingStatus.PENDING);
}
}
@@ -0,0 +1,56 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class ListingPrivacyTest {
private PropertyListing listing() {
PropertyListing listing = new PropertyListing();
listing.setOwnerEmail("wlasciciel@example.com");
listing.setContactEmail("wlasciciel@example.com");
listing.setStatus(ListingStatus.APPROVED);
return listing;
}
@Test
void approximateLocationStaysInsideCircleAndDoesNotDependOnListingId() {
// Regresja: przesuniecie liczone z jawnego id dalo sie odwrocic i odzyskac dokladny adres.
PropertyListing a = listing();
a.setLat(52.229676);
a.setLng(21.012229);
PropertyListing b = listing();
b.setLat(52.229700);
b.setLng(21.012300);
double lat = ListingDetailResponse.blurLat(a);
double lng = ListingDetailResponse.blurLng(a);
// Dwa rozne adresy w tej samej kratce daja ten sam punkt - nie da sie z niego wrocic do adresu.
assertThat(ListingDetailResponse.blurLat(b)).isEqualTo(lat);
assertThat(ListingDetailResponse.blurLng(b)).isEqualTo(lng);
double dLatM = (lat - a.getLat()) * 111_320d;
double dLngM = (lng - a.getLng()) * 111_320d * Math.cos(Math.toRadians(a.getLat()));
assertThat(Math.hypot(dLatM, dLngM)).isLessThan(300d);
}
@Test
void publicListDoesNotExposeOwnerEmail() {
// Regresja: anonimowe GET /api/listings zwracalo e-mail (login) kazdego ogloszeniodawcy.
assertThat(ListingResponse.publicFrom(listing()).ownerEmail()).isNull();
assertThat(ListingResponse.from(listing()).ownerEmail()).isEqualTo("wlasciciel@example.com");
}
@Test
void publicDetailHidesAccountEmailButOwnerSeesIt() {
ListingDetailResponse publicView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, true);
assertThat(publicView.ownerEmail()).isNull();
assertThat(publicView.contactEmail()).isNull();
assertThat(publicView.ownerId()).isEqualTo(7L);
ListingDetailResponse ownerView = ListingDetailResponse.from(listing(), 7L, null, null, null, null, null, false, false);
assertThat(ownerView.ownerEmail()).isEqualTo("wlasciciel@example.com");
assertThat(ownerView.contactEmail()).isEqualTo("wlasciciel@example.com");
}
}
@@ -0,0 +1,33 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
class ListingServiceTest {
@Test
void acceptsRasterImageDataUrls() {
List<String> photos = List.of(
"data:image/jpeg;base64,/9j/4AAQSkZJRg==",
"data:image/png;base64,iVBORw0KGgo=",
"data:image/webp;base64,UklGRg==");
assertThat(ListingService.limitPhotos(photos)).containsExactlyElementsOf(photos);
}
@Test
void rejectsPhotosThatCouldRunScriptOnSiteOrigin() {
// Regresja: data URL z text/html lub SVG serwowany z /api/og/... uruchamial skrypt w domenie serwisu.
for (String photo : List.of(
"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==",
"data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=",
"https://evil.example/redirect",
"javascript:alert(1)")) {
assertThatThrownBy(() -> ListingService.limitPhotos(List.of(photo)))
.isInstanceOf(ResponseStatusException.class);
}
}
}
@@ -0,0 +1,19 @@
package pl.polskalokalnie.listing;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class ListingViewGuardTest {
@Test
void countsOneViewPerVisitorAndListing() {
// Regresja: kazde odswiezenie strony podbijalo licznik wyswietlen.
ListingViewGuard guard = new ListingViewGuard();
assertThat(guard.shouldCount(1L, "203.0.113.1")).isTrue();
assertThat(guard.shouldCount(1L, "203.0.113.1")).isFalse();
assertThat(guard.shouldCount(1L, "203.0.113.2")).isTrue();
assertThat(guard.shouldCount(2L, "203.0.113.1")).isTrue();
}
}
@@ -0,0 +1,53 @@
package pl.polskalokalnie.market;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.junit.jupiter.api.Test;
/** Parsowanie odpowiedzi JSON-stat z Eurostatu - ksztalt jak w zbiorze irt_st_m. */
class EurostatRateClientTest {
private final ObjectMapper mapper = new ObjectMapper();
private String response(String values, String periods) {
return "{\"value\":" + values + ",\"dimension\":{\"time\":{\"category\":{\"index\":" + periods + "}}}}";
}
@Test
void bierzeNajpozniejszyMiesiacZWartoscia() throws Exception {
String json = response("{\"0\":3.85,\"1\":3.82,\"2\":3.83}",
"{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}");
EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json));
assertThat(rate.period()).isEqualTo("2026-08");
assertThat(rate.percent()).isEqualByComparingTo("3.83");
}
@Test
void pomijaMiesiaceBezWartosci() throws Exception {
// Ostatni okres bywa jeszcze pusty - wtedy bierzemy poprzedni, a nie zglaszamy bledu.
String json = response("{\"0\":3.85,\"1\":3.82}",
"{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}");
EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json));
assertThat(rate.period()).isEqualTo("2026-07");
assertThat(rate.percent()).isEqualByComparingTo("3.82");
}
@Test
void odrzucaOdpowiedzBezDanych() throws Exception {
assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree("{}")))
.isInstanceOf(IOException.class);
assertThatThrownBy(() -> EurostatRateClient.parseLatest(
mapper.readTree(response("{}", "{\"2026-08\":0}"))))
.isInstanceOf(IOException.class);
}
@Test
void odrzucaNieprawidlowaStope() throws Exception {
String json = response("{\"0\":0}", "{\"2026-08\":0}");
assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree(json)))
.isInstanceOf(IOException.class);
}
}
@@ -0,0 +1,48 @@
package pl.polskalokalnie.message;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpStatus;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
class ConversationStartTest {
@Test
void cannotStartConversationAboutUnpublishedListing() {
// Regresja: rozmowa o ogloszeniu oczekujacym/odrzuconym ujawniala jego tytul, cene i sprzedajacego.
ConversationRepository conversations = mock(ConversationRepository.class);
ListingRepository listings = mock(ListingRepository.class);
ConversationService service = new ConversationService(conversations, mock(MessageRepository.class),
mock(UserBlockRepository.class), mock(UserRepository.class), listings,
mock(TextModerationService.class), mock(ApplicationEventPublisher.class));
PropertyListing pending = new PropertyListing();
pending.setStatus(ListingStatus.PENDING);
pending.setOwnerEmail("sprzedajacy@example.com");
when(listings.findById(5L)).thenReturn(Optional.of(pending));
AppUser me = new AppUser();
ReflectionTestUtils.setField(me, "id", 1L);
when(conversations.findByListingIdAndBuyerId(5L, 1L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.start(me, 5L, "Dzień dobry"))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
verify(conversations, never()).save(any());
}
}
@@ -0,0 +1,23 @@
package pl.polskalokalnie.og;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class OgControllerTest {
@Test
void decodesRasterImage() {
OgController.DecodedImage image = OgController.decodeDataUrl("data:image/png;base64,iVBORw0KGgo=");
assertThat(image).isNotNull();
assertThat(image.mimeType()).isEqualTo("image/png");
}
@Test
void refusesMimeTypesThatBrowsersExecute() {
// Regresja: typ MIME z data URL trafial wprost do Content-Type odpowiedzi.
assertThat(OgController.decodeDataUrl("data:text/html;base64,PHNjcmlwdD48L3NjcmlwdD4=")).isNull();
assertThat(OgController.decodeDataUrl("data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=")).isNull();
assertThat(OgController.decodeDataUrl("https://evil.example/")).isNull();
}
}
@@ -0,0 +1,30 @@
package pl.polskalokalnie.pricealert;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Prog dla alertow lokalizacji. Cena pojedynczego ogloszenia progu nie ma - tam kazda zmiana
* jest istotna dla obserwujacego, wiec sprawdzamy wylacznie regule dla sredniej rynkowej.
*/
class PriceAlertThresholdTest {
private boolean notifiesLocation(long previous, long current) {
double changePct = ((current - previous) * 100.0) / previous;
return Math.abs(changePct) >= PriceAlertService.LOCATION_MIN_CHANGE_PCT;
}
@Test
void ignoresSmallMoveOfTheAverage() {
// 14 000 -> 14 200 to +1,4%: ponizej progu, bo srednia rusza sie przy kazdym nowym ogloszeniu.
assertThat(notifiesLocation(14_000, 14_200)).isFalse();
assertThat(notifiesLocation(14_000, 13_800)).isFalse();
}
@Test
void notifiesAboutRealMove() {
assertThat(notifiesLocation(14_000, 14_280)).isTrue();
assertThat(notifiesLocation(14_659, 13_523)).isTrue();
}
}
@@ -0,0 +1,37 @@
package pl.polskalokalnie.pricehistory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.junit.jupiter.api.Test;
/** Parsowanie rocznych wartosci z BDL - przecietne wynagrodzenie brutto dla calej Polski. */
class GusWageParsingTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void bierzeNajnowszyRok() throws Exception {
String json = "{\"results\":[{\"id\":\"000000000000\",\"name\":\"POLSKA\",\"values\":["
+ "{\"year\":\"2023\",\"val\":7595.30},{\"year\":\"2025\",\"val\":9371.11},"
+ "{\"year\":\"2024\",\"val\":8630.27}]}]}";
GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json));
assertThat(wage.year()).isEqualTo(2025);
assertThat(wage.value()).isEqualByComparingTo("9371.11");
}
@Test
void pomijaLataBezWartosci() throws Exception {
String json = "{\"results\":[{\"values\":[{\"year\":\"2024\",\"val\":8630.27},{\"year\":\"2025\",\"val\":0}]}]}";
GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json));
assertThat(wage.year()).isEqualTo(2024);
}
@Test
void odrzucaPustaOdpowiedz() {
assertThatThrownBy(() -> GusBdlClient.parseLatestAnnualValue(mapper.readTree("{\"results\":[]}")))
.isInstanceOf(IOException.class);
}
}
@@ -0,0 +1,205 @@
package pl.polskalokalnie.promotion;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
/**
* Zabezpieczenia obslugi notyfikacji ITN (scenariusze przeniesione z Gieldy Komunalnej):
* podszycie sie pod bramke, zanizenie kwoty, podmiana waluty, statusy nie-koncowe i powtorki.
*/
class AutoPayItnServiceTest {
private static final String SERVICE_ID = "218886";
private static final String KEY = "sekretny-klucz-hash";
private static final String ORDER_ID = "abc123";
private final AutoPayService autoPayService = new AutoPayService();
private PromotionOrderRepository orderRepository;
private PromotionService promotionService;
private AutoPayItnService service;
private PaymentConfig cfg;
private PromotionOrder order;
@BeforeEach
void setUp() {
orderRepository = mock(PromotionOrderRepository.class);
promotionService = mock(PromotionService.class);
PaymentConfigService configService = mock(PaymentConfigService.class);
cfg = new PaymentConfig();
cfg.setServiceId(SERVICE_ID);
cfg.setSecretKey(KEY);
cfg.setEnabled(true);
when(configService.get()).thenReturn(cfg);
service = new AutoPayItnService(configService, autoPayService, orderRepository, promotionService);
order = new PromotionOrder();
order.setExternalId(ORDER_ID);
order.setAmount(new BigDecimal("49.99"));
when(orderRepository.findForUpdateByExternalId(ORDER_ID)).thenReturn(Optional.of(order));
}
private String itn(String amount, String currency, String status) {
String hash = autoPayService.hash(cfg, List.of(
SERVICE_ID, ORDER_ID, "remote-1", amount, currency, "21", "20260827120000", status, "OK"));
return encode(xml(amount, currency, status, hash));
}
private String xml(String amount, String currency, String status, String hash) {
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
+ "<transactionList>"
+ "<serviceID>" + SERVICE_ID + "</serviceID>"
+ "<transactions><transaction>"
+ "<orderID>" + ORDER_ID + "</orderID>"
+ "<remoteID>remote-1</remoteID>"
+ "<amount>" + amount + "</amount>"
+ "<currency>" + currency + "</currency>"
+ "<gatewayID>21</gatewayID>"
+ "<paymentDate>20260827120000</paymentDate>"
+ "<paymentStatus>" + status + "</paymentStatus>"
+ "<paymentStatusDetails>OK</paymentStatusDetails>"
+ "</transaction></transactions>"
+ "<hash>" + hash + "</hash>"
+ "</transactionList>";
}
private static String encode(String xml) {
return Base64.getEncoder().encodeToString(xml.getBytes(StandardCharsets.UTF_8));
}
private static String sha256(String input) throws Exception {
byte[] d = MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : d) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
@Test
void hashFollowsDocumentationVector() throws Exception {
PaymentConfig doc = new PaymentConfig();
doc.setSecretKey("2test2");
assertThat(autoPayService.hash(doc, List.of("2", "100", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2"));
// Puste pole opcjonalne pomijane razem z separatorem.
assertThat(autoPayService.hash(doc, List.of("2", "100", "", "1.50"))).isEqualTo(sha256("2|100|1.50|2test2"));
}
@Test
void confirmsValidSuccessNotification() {
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(response).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService).applyPaid(order);
}
@Test
void rejectsForgedSignature() {
String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", "0000000000000000")));
assertThat(response).contains("<confirmation>NOTCONFIRMED</confirmation>");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsSignatureFromDifferentKey() {
PaymentConfig attacker = new PaymentConfig();
attacker.setSecretKey("klucz-atakujacego");
String hash = autoPayService.hash(attacker, List.of(
SERVICE_ID, ORDER_ID, "remote-1", "49.99", "PLN", "21", "20260827120000", "SUCCESS", "OK"));
String response = service.handle(encode(xml("49.99", "PLN", "SUCCESS", hash)));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void pendingPaymentIsAcknowledgedButNotPaid() {
// Regresja: PENDING byl traktowany jak oplacona platnosc.
String response = service.handle(itn("49.99", "PLN", "PENDING"));
assertThat(response).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService, never()).applyPaid(any());
}
@Test
void failedPaymentMarksOrderFailed() {
service.handle(itn("49.99", "PLN", "FAILURE"));
assertThat(order.getStatus()).isEqualTo(OrderStatus.FAILED);
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsUnderpayment() {
String response = service.handle(itn("0.01", "PLN", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsWrongCurrency() {
String response = service.handle(itn("49.99", "EUR", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void isIdempotentOnRetry() {
service.handle(itn("49.99", "PLN", "SUCCESS"));
order.setStatus(OrderStatus.PAID); // applyPaid jest zamockowane - odwzorowujemy skutek
String second = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(second).contains("<confirmation>CONFIRMED</confirmation>");
verify(promotionService, times(1)).applyPaid(order);
}
@Test
void rejectsUnknownOrderId() {
when(orderRepository.findForUpdateByExternalId(ORDER_ID)).thenReturn(Optional.empty());
String response = service.handle(itn("49.99", "PLN", "SUCCESS"));
assertThat(response).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void rejectsNotificationsWhenGatewayIsNotConfigured() {
String valid = itn("49.99", "PLN", "SUCCESS");
cfg.setEnabled(false);
assertThat(service.handle(valid)).contains("NOTCONFIRMED");
verify(promotionService, never()).applyPaid(any());
}
@Test
void refusesXmlWithExternalEntities() {
String xxe = "<?xml version=\"1.0\"?><!DOCTYPE x [<!ENTITY e SYSTEM \"file:///etc/passwd\">]>"
+ "<transactionList><serviceID>&e;</serviceID></transactionList>";
assertThatThrownBy(() -> service.handle(encode(xxe))).isInstanceOf(ResponseStatusException.class);
}
}
@@ -0,0 +1,123 @@
package pl.polskalokalnie.promotion;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.listing.ListingRepository;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.payment.AutoPayService;
import pl.polskalokalnie.payment.PaymentConfig;
import pl.polskalokalnie.payment.PaymentConfigService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
class PaymentSecurityTest {
private final PromotionPackageRepository packageRepository = mock(PromotionPackageRepository.class);
private final PromotionOrderRepository orderRepository = mock(PromotionOrderRepository.class);
private final UserRepository userRepository = mock(UserRepository.class);
private final PaymentConfigService paymentConfigService = mock(PaymentConfigService.class);
private final PromotionPlanRepository planRepository = mock(PromotionPlanRepository.class);
private final ListingRepository listingRepository = mock(ListingRepository.class);
private PromotionService service(boolean autoConfirm) {
return new PromotionService(planRepository, packageRepository, orderRepository,
userRepository, listingRepository, paymentConfigService, mock(AutoPayService.class),
autoConfirm);
}
private PropertyListing ownListing() {
PropertyListing listing = new PropertyListing();
listing.setOwnerEmail("user@example.com");
when(listingRepository.findById(10L)).thenReturn(Optional.of(listing));
return listing;
}
private PromotionPlan plan(boolean active) {
PromotionPlan plan = new PromotionPlan();
plan.setName("Plan");
plan.setDurationDays(30);
plan.setPrice(new BigDecimal("89.99"));
plan.setActive(active);
when(planRepository.findById(3L)).thenReturn(Optional.of(plan));
return plan;
}
@Test
void creditIsNotSpentTwice() {
// Regresja: odczyt-sprawdzenie-zapis salda pozwalal rownoleglym zadaniom wydac 1 kredyt kilka razy.
// Teraz o wydaniu decyduje warunkowy UPDATE w bazie - gdy nie zmieni wiersza, promowania nie ma.
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(new AppUser()));
ownListing();
plan(true);
when(userRepository.consumePromotionCredit(any())).thenReturn(0);
assertThatThrownBy(() -> service(false).promoteWithCredit("user@example.com", new CreditPromoteRequest(10L, 3L)))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.CONFLICT));
verify(listingRepository, never()).save(any());
}
@Test
void inactivePlanCannotBeBoughtOrPaidWithCredit() {
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(new AppUser()));
ownListing();
plan(false);
when(userRepository.consumePromotionCredit(any())).thenReturn(1);
assertThatThrownBy(() -> service(false).promoteWithCredit("user@example.com", new CreditPromoteRequest(10L, 3L)))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND));
verify(userRepository, never()).consumePromotionCredit(any());
}
@Test
void checkoutWithoutGatewayIsRefusedInsteadOfBeingFree() {
// Regresja: bez skonfigurowanej bramki zamowienie bylo od razu oplacone - darmowe kredyty dla kazdego.
when(paymentConfigService.get()).thenReturn(new PaymentConfig());
assertThatThrownBy(() -> service(false).checkout("user@example.com",
new CheckoutRequest(PromotionKind.PACKAGE, null, 1L, null, "AutoPay")))
.isInstanceOfSatisfying(ResponseStatusException.class,
ex -> assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE));
verify(orderRepository, never()).save(any());
verify(userRepository, never()).save(any());
}
@Test
void checkoutWithoutGatewayConfirmsOnlyWhenAutoConfirmIsEnabled() {
when(paymentConfigService.get()).thenReturn(new PaymentConfig());
PromotionPackage pack = new PromotionPackage();
pack.setName("Pakiet");
pack.setQuantity(5);
pack.setPrice(new BigDecimal("199.99"));
when(packageRepository.findById(any())).thenReturn(Optional.of(pack));
when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
AppUser user = new AppUser();
when(userRepository.findByEmailIgnoreCase("user@example.com")).thenReturn(Optional.of(user));
service(true).checkout("user@example.com", new CheckoutRequest(PromotionKind.PACKAGE, null, 1L, null, "AutoPay"));
verify(userRepository).addPromotionCredits(user.getId(), 5);
}
@Test
void paymentDescriptionUsesOnlyCharactersAllowedByGateway() {
PromotionOrder order = new PromotionOrder();
order.setKind(PromotionKind.PLAN);
order.setListingId(42L);
assertThat(PromotionService.paymentDescription(order))
.isEqualTo("Promowanie ogloszenia nr 42")
.matches("[A-Za-z0-9 .:,-]{1,79}");
}
}
@@ -0,0 +1,120 @@
package pl.polskalokalnie.search;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import pl.polskalokalnie.listing.OfferType;
import pl.polskalokalnie.listing.PropertyListing;
import pl.polskalokalnie.listing.PropertyType;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsRepository;
/**
* Dopasowanie nowego ogloszenia do zapisanego wyszukiwania. Puste kryterium znaczy
* "bez ograniczenia" - nie moze odrzucac ofert.
*/
class SavedSearchNotifierTest {
private final SavedSearchNotifier notifier = new SavedSearchNotifier(
mock(SavedSearchRepository.class), mock(UserSettingsRepository.class),
mock(UserRepository.class), mock(NotificationService.class));
private PropertyListing listing() {
PropertyListing listing = new PropertyListing();
listing.setTitle("Mieszkanie 3 pokoje");
listing.setOfferType(OfferType.SALE);
listing.setPropertyType(PropertyType.APARTMENT);
listing.setCity("Gdańsk");
listing.setPrice(BigDecimal.valueOf(750_000));
listing.setArea(62.0);
listing.setRooms(3);
listing.setMarket("Wtórny");
return listing;
}
private SavedSearch search() {
SavedSearch search = new SavedSearch();
search.setMode("buy");
return search;
}
@Test
void pusteWyszukiwaniePasujeDoKazdejOfertySprzedazy() {
assertThat(notifier.matches(search(), listing())).isTrue();
}
@Test
void wyszukiwanieWynajmuNiePasujeDoSprzedazy() {
SavedSearch search = search();
search.setMode("rent");
assertThat(notifier.matches(search, listing())).isFalse();
}
@Test
void miastoMusiSieZgadzac() {
SavedSearch search = search();
search.setCity("Gdańsk");
assertThat(notifier.matches(search, listing())).isTrue();
search.setCity("Kraków");
assertThat(notifier.matches(search, listing())).isFalse();
}
@Test
void cenaPozaWidelkamiOdrzucaOferte() {
SavedSearch search = search();
search.setPriceMax(700_000L);
assertThat(notifier.matches(search, listing())).isFalse();
search.setPriceMax(800_000L);
assertThat(notifier.matches(search, listing())).isTrue();
search.setPriceMin(900_000L);
assertThat(notifier.matches(search, listing())).isFalse();
}
@Test
void metrazIPokojeDzialajaJakoWartosciMinimalne() {
SavedSearch search = search();
search.setAreaMin(70);
assertThat(notifier.matches(search, listing())).isFalse();
search.setAreaMin(50);
search.setRooms(4);
assertThat(notifier.matches(search, listing())).isFalse();
search.setRooms(2);
assertThat(notifier.matches(search, listing())).isTrue();
}
@Test
void rynekOgraniczaTylkoWtedyGdyWybranoJeden() {
SavedSearch search = search();
search.setSecondaryMarket(true);
search.setPrimaryMarket(true);
assertThat(notifier.matches(search, listing())).isTrue();
search.setSecondaryMarket(false);
assertThat(notifier.matches(search, listing())).isFalse();
search.setSecondaryMarket(true);
search.setPrimaryMarket(false);
assertThat(notifier.matches(search, listing())).isTrue();
}
@Test
void rodzajNieruchomosciSpozaOfertySerwisuNigdyNiePasuje() {
SavedSearch search = search();
search.setPropertyType("Działka");
assertThat(notifier.matches(search, listing())).isFalse();
search.setPropertyType("Mieszkanie");
assertThat(notifier.matches(search, listing())).isTrue();
search.setPropertyType("Dom");
assertThat(notifier.matches(search, listing())).isFalse();
}
}
@@ -0,0 +1,34 @@
package pl.polskalokalnie.send;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
class MessageComposerTest {
private final UnsubscribeService unsubscribeService = mock(UnsubscribeService.class);
private final MessageComposer composer = new MessageComposer(unsubscribeService, "https://polskalokalnie.pl");
@Test
void nameInHtmlCampaignIsEscaped() {
// Regresja: imie z publicznego formularza trafialo do maila kampanii jako HTML.
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
String html = composer.composeEmail("<p>Dzień dobry {{name}}!</p>", "<a href=\"https://evil\">X</a>", 1L);
assertThat(html).doesNotContain("href=\"https://evil\"").contains("&lt;a href=&quot;https://evil&quot;&gt;");
}
@Test
void nameInPlainTextCampaignIsEscaped() {
when(unsubscribeService.generateToken(any(), any())).thenReturn("t");
String html = composer.composeEmail("Dzień dobry {{name}}!", "<b>Jan</b>", 1L);
assertThat(html).doesNotContain("<b>Jan</b>").contains("&lt;b&gt;Jan&lt;/b&gt;");
}
}
+8 -5
View File
@@ -1,12 +1,15 @@
spring:
datasource:
url: jdbc:h2:mem:polskalokalnie;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
username: sa
password:
driver-class-name: org.h2.Driver
# Testcontainers: przy pierwszym polaczeniu startuje tymczasowy PostgreSQL 16 (ta sama wersja co
# produkcja w docker-compose), wspolny dla wszystkich testow i usuwany po ich zakonczeniu.
# Wymaga uruchomionego Dockera przy "mvn test".
url: jdbc:tc:postgresql:16-alpine:///polskalokalnie
driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver
jpa:
hibernate:
ddl-auto: create-drop
# Jak na produkcji. create-drop przy kazdym nowym kontekscie Springa kasowal tabele, z ktorych
# korzystal juz dzialajacy kontekst; baza i tak jest swieza przy kazdym uruchomieniu testow.
ddl-auto: update
app:
jwt:
+7 -4
View File
@@ -31,13 +31,16 @@ services:
# Bez tych zmiennych backend bierze wartosci domyslne z application.yml, wiec login i haslo
# administratora ustawione w .env nie mialy zadnego wplywu na dzialajaca aplikacje.
APP_ADMIN_EMAIL: ${APP_ADMIN_EMAIL:-admin@softspm.com}
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-Admin123!}
# Bez wartosci domyslnej: brak hasla w .env.production oznacza, ze konto administratora nie
# zostanie zalozone (blad w logu), zamiast powstac z jawnym haslem z repozytorium.
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:-}
APP_ADMIN_NAME: ${APP_ADMIN_NAME:-Administrator Polska Lokalnie}
# true = przy starcie ustaw haslo administratora na APP_ADMIN_PASSWORD (odzyskanie dostepu).
APP_ADMIN_RESET_PASSWORD: ${APP_ADMIN_RESET_PASSWORD:-false}
# Sekret podpisujacy tokeny sesji. Domyslna wartosc jest jawna w repozytorium - w produkcji
# musi byc wlasna, inaczej kazdy moze wystawic sobie token administratora.
APP_JWT_SECRET: ${APP_JWT_SECRET:-local-dev-secret-change-me-please-32chars-min}
# Sekret podpisujacy tokeny sesji - bez wartosci domyslnej. Gdyby .env.production sie nie
# wczytal, wdrozenie ma sie zatrzymac, a nie wystartowac z jawnym sekretem z repozytorium
# (z nim kazdy moglby wystawic sobie token administratora).
APP_JWT_SECRET: ${APP_JWT_SECRET:?Brak APP_JWT_SECRET w .env.production}
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost}
depends_on:
db:
+3
View File
@@ -39,6 +39,9 @@ services:
# musi byc wlasna, inaczej kazdy moze wystawic sobie token administratora.
APP_JWT_SECRET: ${APP_JWT_SECRET:-local-dev-secret-change-me-please-32chars-min}
APP_PUBLIC_BASE_URL: ${APP_PUBLIC_BASE_URL:-http://localhost}
# Lokalnie promowanie dziala bez kluczy AutoPay (platnosc potwierdzana automatycznie).
# docker-compose.prod.yml tej zmiennej nie przekazuje - na produkcji zawsze false.
APP_PAYMENTS_AUTO_CONFIRM: ${APP_PAYMENTS_AUTO_CONFIRM:-true}
depends_on:
db:
condition: service_healthy
+1043 -910
View File
File diff suppressed because it is too large Load Diff
+71 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ROUTES } from './routes';
import { useAuth } from './auth';
import { apiFetch, useAuth } from './auth';
const errMsg = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd. Spróbuj ponownie.');
@@ -65,6 +65,76 @@ export function ActivationPage() {
);
}
type AutoPayStartForm = {
gatewayUrl: string;
serviceId: string;
orderId: string;
amount: string;
description: string;
customerEmail: string | null;
hash: string;
};
// /platnosc/:orderId - przekierowanie do bramki AutoPay. Transakcje startuje sie formularzem POST,
// wiec podpisane pola pobieramy z API i wysylamy automatycznie. Podpis nie trafia do paska adresu,
// historii przegladarki ani naglowka Referer.
export function PaymentRedirectPage() {
const { orderId } = useParams<{ orderId: string }>();
const [form, setForm] = useState<AutoPayStartForm | null>(null);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
const ran = useRef(false);
useEffect(() => {
if (ran.current) {
return;
}
ran.current = true;
apiFetch<AutoPayStartForm>(`/promotion/order/${encodeURIComponent(orderId ?? '')}/autopay-form`)
.then(setForm)
.catch((e) => setError(errMsg(e)));
}, [orderId]);
// Automatyczna wysylka formularza po pobraniu podpisanych pol.
useEffect(() => {
if (form) {
formRef.current?.submit();
}
}, [form]);
if (error) {
return (
<AuthStatusCard>
<div className="auth-status-icon err">!</div>
<h1>Nie udało się rozpocząć płatności</h1>
<p className="auth-status-lead">{error}</p>
<Link className="auth-status-btn" to={ROUTES.accountListings}>Wróć do moich ofert</Link>
</AuthStatusCard>
);
}
return (
<AuthStatusCard>
<h1>Przekierowanie do płatności</h1>
<p className="auth-status-lead">
{form ? 'Za chwilę nastąpi przekierowanie do AutoPay...' : 'Przygotowujemy bezpieczną płatność...'}
</p>
{form && (
<form ref={formRef} method="POST" action={form.gatewayUrl}>
<input type="hidden" name="ServiceID" value={form.serviceId} />
<input type="hidden" name="OrderID" value={form.orderId} />
<input type="hidden" name="Amount" value={form.amount} />
<input type="hidden" name="Description" value={form.description} />
{form.customerEmail && <input type="hidden" name="CustomerEmail" value={form.customerEmail} />}
<input type="hidden" name="Hash" value={form.hash} />
{/* Zapasowy przycisk, gdyby automatyczna wysylka nie zadzialala. */}
<button className="auth-status-btn" type="submit">Kontynuuj do płatności</button>
</form>
)}
</AuthStatusCard>
);
}
// /reset-hasla/:token - ustawienie nowego hasla.
export function PasswordResetPage() {
const { token } = useParams<{ token: string }>();
+1 -1
View File
@@ -217,7 +217,7 @@ export function AdminPromotionView({ Icon }: { Icon: IconComponent }) {
<span className="mkt-config-icon"><Icon name="credit-card" /></span>
<h2>Bramka szybkich płatności - AutoPay</h2>
</div>
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych działa tryb sandbox (płatność potwierdzana automatycznie).</p>
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych i włączonej bramki zakup promowania jest niedostępny. Adres ITN w panelu AutoPay (Ustawienia serwisu → Konfiguracja techniczna → Edytuj): {window.location.origin}/api/payments/autopay/itn</p>
<div className="mkt-config-grid">
<label>ServiceID
<input value={pay.serviceId} onChange={(e) => setPay({ ...pay, serviceId: e.target.value })} placeholder="np. 123456" />
+10 -7
View File
@@ -36,7 +36,8 @@ export type UserSettings = {
notifySavedSearches: boolean;
notifyPriceAlerts: boolean;
notifyMessages: boolean;
notifyProductNews: boolean;
// Lokalizacje sprawdzone w Historii cen (JSON) - historia przegladania, bez powiadomien.
priceHistoryMarkets: string | null;
searchLocations: string | null;
searchPropertyType: string | null;
searchBudgetMax: number | null;
@@ -76,7 +77,6 @@ export type AuthUser = {
contactPreference: ContactPreference;
preferredLanguage: PreferredLanguage;
nip: string | null;
birthDate: string | null;
verified: boolean;
phoneVerified: boolean;
blocked: boolean;
@@ -130,7 +130,7 @@ export type ProfileUpdate = {
fullName: string;
nick?: string;
phone?: string;
birthDate?: string;
// Adres siedziby - tylko konta firmowe. Backend odrzuca go dla kont prywatnych.
address?: string;
// NIP firmy - pomijamy dla kont prywatnych. Pominiete (undefined) = backend nie zmienia NIP-u.
nip?: string;
@@ -260,7 +260,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
apiFetch<RegisterPending>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, fullName, ...details }),
// acceptTerms zapisuje na serwerze moment akceptacji Regulaminu i Polityki prywatnosci
// (rozliczalnosc z art. 7 ust. 1 RODO) - checkbox w formularzu jest warunkiem wyslania.
body: JSON.stringify({ email, password, fullName, acceptTerms: true, ...details }),
}),
[],
);
@@ -298,12 +300,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return updated;
}, []);
// Zmiana hasla konczy wszystkie dotychczasowe sesje - serwer oddaje nowy token dla tej.
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
await apiFetch<void>('/auth/change-password', {
applyAuth(await apiFetch<AuthResponse>('/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
});
}, []);
}));
}, [applyAuth]);
// Sprawdzenie dotychczasowego hasla bez zmiany czegokolwiek. Rzuca bledem, gdy haslo jest zle.
const verifyPassword = useCallback(async (password: string) => {
+338
View File
@@ -0,0 +1,338 @@
/**
* Schematyczne ilustracje ekranow serwisu dla strony /konto/pomoc.
*
* SWIADOMY WYBOR: to inline SVG, a NIE zrzuty ekranu. Rysuja uklad i etykiety ("gdzie co kliknac"),
* nie piksel-perfect wyglad - dzieki temu nie dezaktualizuja sie przy zmianach stylu, sa lekkie
* (zadnych plikow binarnych w repozytorium) i edytuje sie je jak zwykly kod. To samo rozwiazanie
* co w Gieldzie Komunalnej (components/helpIllustrations.tsx).
*
* UTRZYMANIE: przy zmianie ukladu ekranu, nazw pol albo przebiegu poprawiaj ilustracje razem
* z odpowiedzia w FAQ. Drobne zmiany kolorow czy odstepow w aplikacji ilustracji nie uniewazniaja.
*/
import type { ReactNode } from 'react';
/** Wspolna ramka: rysunek w ramce i podpis pod nim. */
function Figure({ caption, children }: { caption: string; children: ReactNode }) {
return (
<figure className="help-figure">
<div className="help-figure-frame">{children}</div>
<figcaption>{caption}</figcaption>
</figure>
);
}
/* Klasy powtarzane w rysunkach - kolory siedza w styles.css, nie w atrybutach. */
const BOX = 'hf-box';
const LABEL = 'hf-label';
const SMALL = 'hf-label-sm';
const TITLE = 'hf-title';
/** Rejestracja: wybor rodzaju konta na starcie. */
export function RegisterIllustration() {
return (
<Figure caption="Rejestracja - najpierw wybierasz rodzaj konta, potem wypełniasz dane.">
<svg viewBox="0 0 520 170" role="img" aria-label="Szkic rejestracji: dwa kafelki wyboru konta - Osoba prywatna i Firma, poniżej pola e-mail i hasło oraz zgoda na regulamin.">
<text x="12" y="20" className={TITLE}>Załóż konto</text>
<text x="12" y="40" className={LABEL}>Rodzaj konta</text>
<rect x="12" y="50" width="240" height="40" rx="8" className="hf-box hf-active" strokeWidth="2" />
<circle cx="32" cy="70" r="7" className="hf-dot-active" />
<text x="48" y="74" className="hf-title hf-text-active">Osoba prywatna</text>
<rect x="268" y="50" width="240" height="40" rx="8" className={BOX} strokeWidth="1" />
<circle cx="288" cy="70" r="7" className="hf-dot" />
<text x="304" y="74" className={LABEL}>Firma (NIP z rejestru GUS)</text>
<rect x="12" y="102" width="240" height="26" rx="6" className={BOX} strokeWidth="1" />
<text x="22" y="119" className={SMALL}>Adres e-mail</text>
<rect x="268" y="102" width="240" height="26" rx="6" className={BOX} strokeWidth="1" />
<text x="278" y="119" className={SMALL}>Hasło (min. 8 znaków)</text>
<rect x="12" y="138" width="14" height="14" rx="3" className="hf-box hf-active" strokeWidth="2" />
<text x="34" y="150" className={SMALL}>Akceptuję Regulamin i Politykę prywatności</text>
</svg>
</Figure>
);
}
/** Aktywacja konta: link z e-maila. */
export function ActivationIllustration() {
return (
<Figure caption="Po rejestracji konto czeka na aktywację - klikasz link z wiadomości e-mail.">
<svg viewBox="0 0 520 120" role="img" aria-label="Szkic przebiegu aktywacji: rejestracja, wiadomość e-mail z linkiem, aktywne konto.">
{[
{ x: 12, title: '1. Rejestracja', sub: 'formularz na stronie' },
{ x: 188, title: '2. E-mail', sub: 'link aktywacyjny' },
{ x: 364, title: '3. Logowanie', sub: 'konto gotowe' },
].map((step) => (
<g key={step.title}>
<rect x={step.x} y="28" width="144" height="60" rx="8" className={BOX} strokeWidth="1" />
<text x={step.x + 14} y="54" className={TITLE}>{step.title}</text>
<text x={step.x + 14} y="72" className={SMALL}>{step.sub}</text>
</g>
))}
<path d="M162 58 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
<path d="M338 58 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
</svg>
</Figure>
);
}
/** Logowanie wylacznie adresem e-mail. */
export function LoginIllustration() {
return (
<Figure caption="Logujesz się adresem e-mail - nazwa użytkownika (nick) służy tylko do adresu profilu.">
<svg viewBox="0 0 520 140" role="img" aria-label="Szkic logowania: pole e-mail, pole hasła, odnośnik do odzyskiwania hasła i przycisk Zaloguj się.">
<text x="12" y="22" className={TITLE}>Zaloguj się</text>
<rect x="12" y="34" width="330" height="28" rx="6" className={BOX} strokeWidth="1" />
<text x="22" y="52" className={SMALL}>jan.kowalski@example.com</text>
<rect x="12" y="70" width="330" height="28" rx="6" className={BOX} strokeWidth="1" />
<text x="22" y="88" className={SMALL}>••••••••</text>
<text x="12" y="118" className="hf-label-sm hf-text-active">Nie pamiętam hasła</text>
<rect x="358" y="34" width="150" height="64" rx="8" className="hf-fill-active" />
<text x="398" y="72" className="hf-title hf-text-white">Zaloguj się</text>
</svg>
</Figure>
);
}
/** Formularz dodawania ogloszenia i droga przez moderacje. */
export function AddListingIllustration() {
return (
<Figure caption="Dodawanie ogłoszenia - wypełniasz formularz, a ogłoszenie czeka na akceptację obsługi.">
<svg viewBox="0 0 520 180" role="img" aria-label="Szkic formularza ogłoszenia: rodzaj oferty, tytuł, cena, metraż i zdjęcia, obok ścieżka od wysłania do publikacji.">
<text x="12" y="20" className={TITLE}>Dodaj ogłoszenie</text>
<rect x="12" y="32" width="120" height="30" rx="6" className="hf-box hf-active" strokeWidth="2" />
<text x="34" y="51" className="hf-title hf-text-active">Sprzedaż</text>
<rect x="140" y="32" width="120" height="30" rx="6" className={BOX} strokeWidth="1" />
<text x="170" y="51" className={LABEL}>Wynajem</text>
<rect x="12" y="72" width="248" height="26" rx="6" className={BOX} strokeWidth="1" />
<text x="22" y="89" className={SMALL}>Tytuł ogłoszenia</text>
<rect x="12" y="106" width="120" height="26" rx="6" className={BOX} strokeWidth="1" />
<text x="22" y="123" className={SMALL}>Cena</text>
<rect x="140" y="106" width="120" height="26" rx="6" className={BOX} strokeWidth="1" />
<text x="150" y="123" className={SMALL}>Metraż</text>
<rect x="12" y="140" width="248" height="28" rx="6" className={BOX} strokeWidth="1" strokeDasharray="4 3" />
<text x="22" y="158" className={SMALL}>Zdjęcia - przeciągnij albo wybierz z dysku</text>
{[
{ y: 36, title: 'Wysyłasz', sub: 'status: oczekuje' },
{ y: 90, title: 'Moderacja', sub: 'sprawdzamy treść' },
{ y: 144, title: 'Publikacja', sub: 'widoczne w ofertach' },
].map((step) => (
<g key={step.title}>
<rect x="290" y={step.y - 20} width="218" height="40" rx="8" className={BOX} strokeWidth="1" />
<circle cx="312" cy={step.y} r="8" className="hf-dot-active" />
<text x="330" y={step.y - 2} className={TITLE}>{step.title}</text>
<text x="330" y={step.y + 12} className={SMALL}>{step.sub}</text>
</g>
))}
</svg>
</Figure>
);
}
/** Panel "Moje oferty" z akcjami przy ogloszeniu. */
export function MyListingsIllustration() {
return (
<Figure caption="Moje oferty - przy każdym ogłoszeniu masz edycję, wstrzymanie, wyróżnienie i usunięcie.">
<svg viewBox="0 0 520 170" role="img" aria-label="Szkic listy Moje oferty: dwa wiersze ogłoszeń ze statusem oraz przyciskami Edytuj, Wstrzymaj i Wyróżnij.">
<text x="12" y="20" className={TITLE}>Moje oferty</text>
{[
{ y: 34, status: 'Opublikowane', tone: 'hf-fill-ok' },
{ y: 104, status: 'Oczekuje', tone: 'hf-fill-wait' },
].map((row) => (
<g key={row.status}>
<rect x="12" y={row.y} width="496" height="56" rx="8" className={BOX} strokeWidth="1" />
<rect x="22" y={row.y + 10} width="50" height="36" rx="4" className="hf-photo" />
<rect x="84" y={row.y + 12} width="150" height="9" rx="3" className="hf-line" />
<rect x="84" y={row.y + 28} width="78" height="14" rx="4" className={row.tone} />
<text x="92" y={row.y + 39} className="hf-label-sm hf-text-white">{row.status}</text>
<text x="250" y={row.y + 25} className="hf-title hf-text-active">690 000 zł</text>
<text x="250" y={row.y + 41} className={SMALL}>Gdańsk, 58 m²</text>
{['Edytuj', 'Wstrzymaj', 'Wyróżnij'].map((action, index) => (
<g key={action}>
<rect x={358 + index * 52} y={row.y + 18} width="48" height="20" rx="4" className={BOX} strokeWidth="1" />
<text x={364 + index * 52} y={row.y + 32} className={SMALL}>{action}</text>
</g>
))}
</g>
))}
</svg>
</Figure>
);
}
/** Wyroznienie ogloszenia i platnosc. */
export function PromotionIllustration() {
return (
<Figure caption="Wyróżnienie - wybierasz pakiet, płacisz przez AutoPay albo zużywasz kredyt z pakietu.">
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic wyróżniania: trzy pakiety do wyboru, poniżej ścieżka płatności AutoPay i powrót do serwisu.">
<text x="12" y="20" className={TITLE}>Wyróżnij ogłoszenie</text>
{/* Bez konkretnych kwot - cennik ustawia administrator i moze sie zmienic,
a rysunek ma pokazywac uklad ekranu, nie aktualna oferte. */}
{[
{ x: 12, name: 'Krótkie', active: true },
{ x: 184, name: 'Średnie', active: false },
{ x: 356, name: 'Długie', active: false },
].map((plan) => (
<g key={plan.name}>
<rect
x={plan.x}
y="32"
width="152"
height="52"
rx="8"
className={plan.active ? 'hf-box hf-active' : BOX}
strokeWidth={plan.active ? 2 : 1}
/>
<text x={plan.x + 16} y="54" className={TITLE}>{plan.name} wyróżnienie</text>
<text x={plan.x + 16} y="72" className={plan.active ? 'hf-label-sm hf-text-active' : SMALL}>liczba dni i cena z cennika</text>
</g>
))}
{[
{ x: 12, label: 'Wybór pakietu' },
{ x: 188, label: 'AutoPay - płatność' },
{ x: 364, label: 'Ogłoszenie na górze listy' },
].map((step) => (
<g key={step.label}>
<rect x={step.x} y="102" width="144" height="34" rx="8" className={BOX} strokeWidth="1" />
<text x={step.x + 12} y="124" className={SMALL}>{step.label}</text>
</g>
))}
<path d="M162 119 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
<path d="M338 119 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
</svg>
</Figure>
);
}
/** Wyszukiwanie z filtrami i zapisem wyszukiwania. */
export function SearchIllustration() {
return (
<Figure caption="Wyszukiwanie - filtry siedzą w adresie strony, więc wynik da się odświeżyć i wysłać znajomemu.">
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic wyszukiwarki: pola lokalizacja, rodzaj, cena i metraż, przycisk Szukaj oraz przycisk Zapisz wyszukiwanie.">
{[
{ x: 12, w: 150, label: 'Lokalizacja', value: 'Gdańsk' },
{ x: 170, w: 120, label: 'Rodzaj', value: 'Mieszkanie' },
{ x: 298, w: 100, label: 'Cena do', value: '800 000' },
].map((field) => (
<g key={field.label}>
<rect x={field.x} y="20" width={field.w} height="44" rx="8" className={BOX} strokeWidth="1" />
<text x={field.x + 12} y="38" className={SMALL}>{field.label}</text>
<text x={field.x + 12} y="55" className={TITLE}>{field.value}</text>
</g>
))}
<rect x="406" y="20" width="102" height="44" rx="8" className="hf-fill-active" />
<text x="437" y="48" className="hf-title hf-text-white">Szukaj</text>
<rect x="12" y="80" width="190" height="30" rx="8" className="hf-box hf-active" strokeWidth="2" />
<text x="30" y="100" className="hf-title hf-text-active">Zapisz wyszukiwanie</text>
<text x="216" y="100" className={SMALL}>Nowe pasujące oferty trafią na listę powiadomień.</text>
<text x="12" y="132" className={SMALL}>Adres: /kupno?miasto=Gdańsk&amp;typ=Mieszkanie&amp;cena_do=800000</text>
</svg>
</Figure>
);
}
/** Alerty cenowe - gdzie sie je zaklada i gdzie ladują. */
export function PriceAlertIllustration() {
return (
<Figure caption="Alerty cenowe - zakładasz je przy ofercie albo w Historii cen, a zmiany trafiają na listę powiadomień.">
<svg viewBox="0 0 520 140" role="img" aria-label="Szkic alertów: przycisk przy ofercie, przycisk w historii cen oraz powiadomienie o zmianie ceny.">
<rect x="12" y="22" width="220" height="44" rx="8" className={BOX} strokeWidth="1" />
<text x="26" y="42" className={SMALL}>Karta oferty</text>
<text x="26" y="58" className="hf-title hf-text-active">Utwórz alert ceny</text>
<rect x="12" y="78" width="220" height="44" rx="8" className={BOX} strokeWidth="1" />
<text x="26" y="98" className={SMALL}>Historia cen</text>
<text x="26" y="114" className="hf-title hf-text-active">Utwórz alert cenowy</text>
<rect x="268" y="40" width="240" height="64" rx="8" className="hf-box hf-active" strokeWidth="2" />
<circle cx="292" cy="62" r="9" className="hf-dot-active" />
<text x="310" y="60" className={TITLE}>Cena spadła: Gdańsk, Wrzeszcz</text>
<text x="310" y="78" className={SMALL}>690 000 zł → 659 000 zł (-4,5%)</text>
<text x="310" y="94" className={SMALL}>Lista powiadomień w serwisie</text>
<path d="M240 62 h20 l-6 -5 m6 5 l-6 5" className="hf-arrow" />
</svg>
</Figure>
);
}
/** Wiadomosci i umawianie ogladania. */
export function MessagesIllustration() {
return (
<Figure caption="Wiadomości - rozmowa dotyczy konkretnego ogłoszenia, a termin oglądania proponujesz w tym samym oknie.">
<svg viewBox="0 0 520 160" role="img" aria-label="Szkic wiadomości: lista rozmów po lewej, rozmowa po prawej z przyciskiem proponowania terminu oglądania.">
<rect x="12" y="16" width="150" height="130" rx="8" className={BOX} strokeWidth="1" />
<text x="24" y="36" className={TITLE}>Rozmowy</text>
{[52, 84, 116].map((y, index) => (
<g key={y}>
<rect x="22" y={y} width="130" height="24" rx="6" className={index === 0 ? 'hf-box hf-active' : 'hf-photo'} strokeWidth={index === 0 ? 2 : 0} />
<text x="32" y={y + 16} className={SMALL}>Mieszkanie {index + 1}</text>
</g>
))}
<rect x="174" y="16" width="334" height="130" rx="8" className={BOX} strokeWidth="1" />
<rect x="186" y="30" width="180" height="22" rx="8" className="hf-photo" />
<text x="196" y="45" className={SMALL}>Czy mieszkanie jest dostępne?</text>
<rect x="316" y="60" width="180" height="22" rx="8" className="hf-fill-active" />
<text x="326" y="75" className="hf-label-sm hf-text-white">Tak, zapraszam na oglądanie</text>
<rect x="186" y="98" width="200" height="30" rx="8" className="hf-box hf-active" strokeWidth="2" />
<text x="202" y="117" className="hf-title hf-text-active">Zaproponuj termin oglądania</text>
<text x="396" y="117" className={SMALL}>Trafi do kalendarza</text>
</svg>
</Figure>
);
}
/** Ustawienia powiadomien. */
export function NotificationSettingsIllustration() {
return (
<Figure caption="Ustawienia konta - trzy przełączniki decydują, co trafia na listę powiadomień.">
<svg viewBox="0 0 520 150" role="img" aria-label="Szkic ustawień powiadomień: przełączniki dla zapisanych wyszukiwań, alertów cenowych i wiadomości oraz osobna sekcja zgód marketingowych.">
<text x="12" y="20" className={TITLE}>Powiadomienia</text>
{[
{ y: 32, label: 'Nowe oferty z zapisanych wyszukiwań', on: true },
{ y: 62, label: 'Alerty cenowe', on: true },
{ y: 92, label: 'Wiadomości i terminy oglądania', on: true },
].map((row) => (
<g key={row.label}>
<rect x="12" y={row.y} width="496" height="26" rx="6" className={BOX} strokeWidth="1" />
<text x="26" y={row.y + 17} className={LABEL}>{row.label}</text>
<rect x="452" y={row.y + 6} width="34" height="16" rx="8" className={row.on ? 'hf-fill-active' : 'hf-photo'} />
<circle cx={row.on ? 478 : 460} cy={row.y + 14} r="6" className="hf-knob" />
</g>
))}
<text x="12" y="138" className={SMALL}>Niżej, pod kreską: zgody marketingowe (newsletter i SMS) - osobna sprawa.</text>
</svg>
</Figure>
);
}
/** Usuniecie konta i zakres kasowanych danych. */
export function AccountDeletionIllustration() {
return (
<Figure caption="Usunięcie konta - kasujemy dane i treści, zostają tylko dokumenty wymagane przepisami.">
<svg viewBox="0 0 520 120" role="img" aria-label="Szkic usuwania konta: lista kasowanych danych po lewej i lista danych zachowywanych po prawej.">
<rect x="12" y="16" width="240" height="90" rx="8" className={BOX} strokeWidth="1" />
<text x="26" y="36" className={TITLE}>Kasujemy</text>
<text x="26" y="56" className={SMALL}>ogłoszenia, wiadomości, ulubione,</text>
<text x="26" y="72" className={SMALL}>zapisane wyszukiwania, alerty,</text>
<text x="26" y="88" className={SMALL}>terminy oglądania, dane profilu</text>
<rect x="268" y="16" width="240" height="90" rx="8" className={BOX} strokeWidth="1" />
<text x="282" y="36" className={TITLE}>Zostaje</text>
<text x="282" y="56" className={SMALL}>rozliczenia płatności (5 lat),</text>
<text x="282" y="72" className={SMALL}>zgłoszenia i moderacja (do 3 lat)</text>
<text x="282" y="88" className={SMALL}>- wymagają tego przepisy</text>
</svg>
</Figure>
);
}
-3
View File
@@ -10,7 +10,6 @@ const en: Record<string, string> = {
'Dane i ustawienia': 'Data and settings',
'Edytuj profil': 'Edit profile',
'Dodatkowe informacje': 'Additional information',
'Adres zamieszkania': 'Residential address',
'Preferencje kontaktu': 'Contact preferences',
'Język komunikacji': 'Communication language',
'Nie podano': 'Not provided',
@@ -97,7 +96,6 @@ const uk: Record<string, string> = {
'Dane i ustawienia': 'Дані та налаштування',
'Edytuj profil': 'Редагувати профіль',
'Dodatkowe informacje': 'Додаткова інформація',
'Adres zamieszkania': 'Адреса проживання',
'Preferencje kontaktu': 'Параметри контакту',
'Język komunikacji': 'Мова спілкування',
'Nie podano': 'Не вказано',
@@ -163,7 +161,6 @@ const de: Record<string, string> = {
'Dane i ustawienia': 'Daten und Einstellungen',
'Edytuj profil': 'Profil bearbeiten',
'Dodatkowe informacje': 'Zusätzliche Informationen',
'Adres zamieszkania': 'Wohnadresse',
'Preferencje kontaktu': 'Kontaktpräferenzen',
'Język komunikacji': 'Kommunikationssprache',
'Nie podano': 'Nicht angegeben',
+636 -174
View File
@@ -1,6 +1,8 @@
// Strony prawne serwisu: Regulamin, Polityka prywatności oraz baner zgody na pliki cookie.
// Treść dostosowana do usług portalu Polska Lokalnie (ogłoszenia nieruchomości bez prowizji,
// płatne promowanie ofert przez AutoPay). Dostawca: SoftSPM, hosting: OVHcloud.
// Strony prawne serwisu: Regulamin, Polityka prywatnosci oraz baner zgody.
//
// ZASADA: dokumenty opisuja wylacznie to, co serwis naprawde robi. Kazda wymieniona tu funkcja,
// kazdy odbiorca danych i kazdy okres przechowywania ma pokrycie w kodzie. Gdy zmienia sie
// funkcjonalnosc albo lista uslug zewnetrznych, te pliki trzeba poprawic razem z nia.
import { useEffect, useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { ROUTES } from './routes';
@@ -12,202 +14,661 @@ const PROVIDER = {
nip: '7691956407',
regon: '100629083',
email: 'info@polskalokalnie.pl',
phone: '+48 453 193 605',
hosting: 'OVHcloud (OVH Sp. z o.o., ul. Swobodna 1, 50-088 Wrocław)',
dataCenter: 'centrum danych w Warszawie',
};
const SERVICE_NAME = 'Polska Lokalnie';
const LAST_UPDATED = '9 sierpnia 2026 r.';
const LAST_UPDATED = '25 września 2026 r.';
function LegalPage({ title, subtitle, children }: { title: string; subtitle: string; children: ReactNode }) {
type LegalSection = {
id: string;
title: string;
body: ReactNode;
};
/**
* Wspolny uklad stron prawnych: ta sama szerokosc i ten sam jezyk wizualny co reszta serwisu
* (/kupno, /jak-sprzedawac), plus spis tresci przyklejony z boku - dokumenty sa dlugie,
* a uzytkownik zwykle szuka jednej konkretnej sekcji.
*/
function LegalDocument({ title, subtitle, intro, sections }: {
title: string;
subtitle: string;
intro: ReactNode;
sections: LegalSection[];
}) {
return (
<section className="legal-page" aria-label={title}>
<div className="legal-shell">
<header className="legal-head">
<p className="legal-brand">Polska Lokalnie <span>|</span> SoftSPM</p>
<div className="legal-container">
{/* Bez okruszkow - w projekcie kazdy widok zaczyna sie od naglowka H1 (patrz styles.css). */}
<header className="legal-title">
<h1>{title}</h1>
<p>{subtitle}</p>
<p className="legal-updated">Data ostatniej aktualizacji: {LAST_UPDATED}</p>
<div className="legal-meta">
<span><strong>{SERVICE_NAME}</strong> · {PROVIDER.name}</span>
<span>Obowiązuje od {LAST_UPDATED}</span>
</div>
</header>
<div className="legal-body">{children}</div>
<div className="legal-layout">
<aside className="legal-toc" aria-label="Spis treści">
<h2>Spis treści</h2>
<ol>
{sections.map((section) => (
<li key={section.id}><a href={`#${section.id}`}>{section.title}</a></li>
))}
</ol>
</aside>
<div className="legal-main">
<div className="legal-card legal-intro">{intro}</div>
{sections.map((section) => (
<article className="legal-card" id={section.id} key={section.id}>
<h2>{section.title}</h2>
{section.body}
</article>
))}
</div>
</div>
</div>
</section>
);
}
export function TermsPage() {
function ProviderDetails() {
return (
<LegalPage
title="Regulamin platformy Polska Lokalnie"
subtitle="Zasady korzystania z platformy ogłoszeniowej oraz świadczenia usług drogą elektroniczną."
>
<h2>§ 1. Postanowienia ogólne</h2>
<ol>
<li>Niniejszy Regulamin określa zasady korzystania z platformy internetowej <strong>{SERVICE_NAME}</strong>, dostępnej pod adresem polskalokalnie.pl (dalej „Platforma").</li>
<li>Właścicielem i dostawcą Platformy (Usługodawcą) jest <strong>{PROVIDER.legal}</strong>, {PROVIDER.address}, NIP: {PROVIDER.nip}, REGON: {PROVIDER.regon}, adres e-mail: {PROVIDER.email}.</li>
<li>Platforma jest utrzymywana na serwerach dostawcy hostingu {PROVIDER.hosting}, które są fizycznie zlokalizowane na terytorium Rzeczypospolitej Polskiej (w Unii Europejskiej).</li>
<li>Regulamin jest udostępniany nieodpłatnie w sposób umożliwiający jego pozyskanie, odtworzenie i utrwalenie.</li>
<li>Korzystanie z Platformy oznacza akceptację postanowień Regulaminu oraz Polityki prywatności.</li>
</ol>
<ul className="legal-facts">
<li><span>Usługodawca</span><strong>{PROVIDER.legal}</strong></li>
<li><span>Adres</span><strong>{PROVIDER.address}</strong></li>
<li><span>NIP</span><strong>{PROVIDER.nip}</strong></li>
<li><span>REGON</span><strong>{PROVIDER.regon}</strong></li>
<li><span>E-mail</span><strong>{PROVIDER.email}</strong></li>
<li><span>Telefon</span><strong>{PROVIDER.phone}</strong></li>
<li><span>Hosting</span><strong>{PROVIDER.hosting}, {PROVIDER.dataCenter}</strong></li>
</ul>
);
}
<h2>§ 2. Definicje</h2>
<ul>
<li><strong>Usługodawca</strong> - {PROVIDER.name}, podmiot prowadzący Platformę.</li>
<li><strong>Użytkownik</strong> - osoba fizyczna, osoba prawna lub jednostka organizacyjna korzystająca z Platformy.</li>
<li><strong>Konsument</strong> - Użytkownik będący osobą fizyczną dokonujący czynności niezwiązanej bezpośrednio z jego działalnością gospodarczą lub zawodową.</li>
<li><strong>Konto</strong> - zbiór zasobów i uprawnień przypisanych Użytkownikowi po rejestracji.</li>
<li><strong>Ogłoszenie</strong> - zamieszczana przez Użytkownika oferta sprzedaży lub najmu nieruchomości.</li>
<li><strong>Usługa promowania (Wyróżnienie)</strong> - odpłatna usługa zwiększająca widoczność Ogłoszenia.</li>
<li><strong>Operator płatności</strong> - dostawca usług szybkich płatności obsługujący płatności w Platformie (AutoPay - Autopay S.A.).</li>
</ul>
export function TermsPage() {
const sections: LegalSection[] = [
{
id: 'postanowienia-ogolne',
title: '§ 1. Postanowienia ogólne',
body: (
<>
<ol>
<li>Regulamin określa zasady korzystania z platformy <strong>{SERVICE_NAME}</strong>, dostępnej pod adresem polskalokalnie.pl (dalej „Platforma").</li>
<li>Usługodawcą jest:</li>
</ol>
<ProviderDetails />
<ol start={3}>
<li>Platforma działa na serwerach {PROVIDER.hosting}, w {PROVIDER.dataCenter}, na terytorium Rzeczypospolitej Polskiej.</li>
<li>Usługodawca świadczy usługę hostingu treści: udostępnia narzędzia, a treść ogłoszeń i wiadomości pochodzi od Użytkowników.</li>
<li>Regulamin jest udostępniany nieodpłatnie w sposób umożliwiający jego pozyskanie, odtworzenie i utrwalenie.</li>
<li>Korzystanie z Platformy oznacza akceptację Regulaminu oraz <Link to={ROUTES.privacy}>Polityki prywatności</Link>.</li>
</ol>
</>
),
},
{
id: 'definicje',
title: '§ 2. Definicje',
body: (
<ul>
<li><strong>Użytkownik</strong> - osoba fizyczna, prawna lub jednostka organizacyjna korzystająca z Platformy.</li>
<li><strong>Konsument</strong> - Użytkownik będący osobą fizyczną, dokonujący czynności niezwiązanej bezpośrednio z działalnością gospodarczą lub zawodową.</li>
<li><strong>Konto</strong> - zbiór zasobów i uprawnień przypisanych Użytkownikowi po rejestracji i aktywacji.</li>
<li><strong>Ogłoszenie</strong> - zamieszczana przez Użytkownika oferta sprzedaży lub najmu nieruchomości.</li>
<li><strong>Wyróżnienie</strong> - odpłatna usługa zwiększająca widoczność Ogłoszenia na listach wyników.</li>
<li><strong>Operator płatności</strong> - Autopay S.A. z siedzibą w Sopocie, obsługujący płatności za usługi odpłatne.</li>
</ul>
),
},
{
id: 'zakres-uslug',
title: '§ 3. Zakres usług',
body: (
<>
<p>W ramach Platformy Użytkownik może korzystać z następujących funkcji:</p>
<ol>
<li><strong>Bezpłatne ogłoszenia</strong> - dodawanie, edycja i wstrzymywanie ofert sprzedaży oraz najmu, bez prowizji od transakcji.</li>
<li><strong>Wyszukiwanie i mapa</strong> - przeglądanie ofert z filtrami, przeglądanie ich na mapie oraz zawężanie wyników do widocznego fragmentu mapy.</li>
<li><strong>Konto i narzędzia osobiste</strong> - ulubione ogłoszenia, zapisane wyszukiwania, porównywarka ofert oraz panel z własnymi ogłoszeniami. Gdy pojawi się Ogłoszenie pasujące do zapisanego wyszukiwania, informacja trafia na listę powiadomień w Platformie; Użytkownik może ją wyłączyć w ustawieniach konta.</li>
<li><strong>Alerty cenowe</strong> - obserwowanie ceny wybranego Ogłoszenia lub średniej ceny za m² w mieście. Zmiany Platforma sprawdza raz na dobę, a informacja o nich trafia <strong>wyłącznie na listę powiadomień w Platformie</strong>. Z alertów nie są wysyłane wiadomości e-mail ani SMS.</li>
<li><strong>Wiadomości</strong> - kontakt między Użytkownikami w sprawie konkretnego Ogłoszenia, z możliwością zablokowania rozmówcy oraz kontakt z obsługą serwisu.</li>
<li><strong>Umawianie oglądania</strong> - proponowanie terminu w rozmowie o Ogłoszeniu; druga strona potwierdza go lub odrzuca, a każda ze stron może termin odwołać. Terminy są widoczne w kalendarzu obu stron.</li>
<li><strong>Narzędzia informacyjne</strong> - historia cen (na podstawie danych Narodowego Banku Polskiego, Głównego Urzędu Statystycznego oraz ogłoszeń w Platformie), orientacyjna wycena nieruchomości, analiza „ile można utargować", ranking dzielnic i kalkulator zdolności kredytowej.</li>
<li><strong>Poradniki</strong> - czytanie artykułów oraz publikowanie własnych, po akceptacji przez obsługę serwisu.</li>
<li><strong>Wyróżnianie Ogłoszeń</strong> - usługa odpłatna, opisana w § 9.</li>
</ol>
<p className="legal-note">
Narzędzia informacyjne (wycena, historia cen, analiza negocjacji, ranking, kalkulator) mają charakter
orientacyjny. Nie stanowią oferty, porady inwestycyjnej ani operatu szacunkowego w rozumieniu przepisów
o gospodarce nieruchomościami.
</p>
</>
),
},
{
id: 'wymagania-techniczne',
title: '§ 4. Wymagania techniczne',
body: (
<ol>
<li>Do korzystania z Platformy potrzebne są: urządzenie z dostępem do Internetu, aktualna przeglądarka z obsługą JavaScript oraz czynne konto poczty elektronicznej.</li>
<li>Platforma zapisuje dane w pamięci przeglądarki (localStorage). Jej zablokowanie uniemożliwia zalogowanie się.</li>
<li>Usługodawca nie odpowiada za problemy wynikające z konfiguracji urządzenia Użytkownika lub działania jego dostawcy Internetu.</li>
</ol>
),
},
{
id: 'konto',
title: '§ 5. Rejestracja i Konto',
body: (
<ol>
<li>Rejestracja wymaga podania adresu e-mail, hasła (co najmniej 8 znaków) oraz imienia i nazwiska albo nazwy firmy, a także akceptacji Regulaminu i Polityki prywatności. Moment akceptacji jest zapisywany.</li>
<li>Konto zakłada osoba pełnoletnia posiadająca pełną zdolność do czynności prawnych, we własnym imieniu albo z umocowaniem do działania za inny podmiot. Pełnoletność potwierdza oświadczenie Użytkownika złożone przez założenie Konta - Platforma nie zbiera w tym celu daty urodzenia ani nie żąda dokumentu tożsamości.</li>
<li>Konto wymaga aktywacji linkiem wysłanym na podany adres e-mail. Do tego czasu logowanie jest zablokowane.</li>
<li>Numer telefonu jest dobrowolny. Użytkownik może go potwierdzić kodem SMS; niepotwierdzony numer nie blokuje korzystania z Platformy.</li>
<li>Przy rejestracji konta firmowego dane firmy mogą zostać pobrane z rejestru REGON prowadzonego przez Główny Urząd Statystyczny na podstawie podanego numeru NIP. Adres siedziby jest polem konta firmowego; Użytkownik będący osobą prywatną nie podaje adresu zamieszkania.</li>
<li>Logowanie odbywa się wyłącznie adresem e-mail i hasłem. Platforma nie oferuje logowania za pośrednictwem kont w serwisach zewnętrznych.</li>
<li>Konto jest przypisane do jednego Użytkownika. Udostępnianie danych logowania osobom trzecim jest niedozwolone.</li>
<li>Użytkownik może w każdej chwili usunąć Konto w panelu konta. Skutki usunięcia opisuje <Link to={ROUTES.privacy}>Polityka prywatności</Link>.</li>
</ol>
),
},
{
id: 'ogloszenia',
title: '§ 6. Zasady publikacji ogłoszeń',
body: (
<ol>
<li>Ogłoszenie może dotyczyć wyłącznie rzeczywistej oferty sprzedaży lub najmu nieruchomości, do której Użytkownik ma tytuł prawny albo stosowne umocowanie.</li>
<li>Treść Ogłoszenia musi być zgodna ze stanem faktycznym, kompletna i nie może wprowadzać w błąd, w szczególności co do ceny, powierzchni i stanu nieruchomości.</li>
<li>Do Ogłoszenia można dodać <strong>do 8 zdjęć</strong> oraz odnośnik do wirtualnego spaceru umieszczonego w serwisie zewnętrznym (YouTube, Matterport, Kuula).</li>
<li>Użytkownik może ograniczyć publikowaną lokalizację do przybliżonego obszaru zamiast dokładnego punktu na mapie.</li>
<li>Zabronione jest zamieszczanie treści bezprawnych, w szczególności naruszających prawa osób trzecich, obraźliwych, dyskryminujących, wprowadzających w błąd oraz treści o charakterze spamu.</li>
<li>Ogłoszenia i artykuły podlegają moderacji przed publikacją. Usługodawca może wstrzymać publikację, wezwać do poprawienia treści albo usunąć materiał naruszający Regulamin lub przepisy prawa, informując o tym Użytkownika.</li>
<li>Użytkownik udziela Usługodawcy niewyłącznej, nieodpłatnej licencji na korzystanie z treści i zdjęć Ogłoszenia w zakresie niezbędnym do jego prezentacji w Platformie oraz w podglądach linku udostępnianego w komunikatorach i serwisach społecznościowych.</li>
</ol>
),
},
{
id: 'zglaszanie-tresci',
title: '§ 7. Zgłaszanie nielegalnych treści i moderacja',
body: (
<>
<p>
Usługodawca jest dostawcą usług hostingu w rozumieniu rozporządzenia (UE) 2022/2065 (akt o usługach
cyfrowych, DSA) i stosuje poniższy tryb zgłaszania treści.
</p>
<ol>
<li><strong>Jak zgłosić.</strong> Przy każdym Ogłoszeniu oraz w rozmowie z Użytkownikiem dostępny jest przycisk zgłoszenia. Zgłoszenie można też wysłać na adres {PROVIDER.email}. Ten sam adres jest punktem kontaktowym dla organów i dla Użytkowników.</li>
<li><strong>Co podać.</strong> Wskazanie treści (odnośnik), wyjaśnienie, dlaczego jest bezprawna lub sprzeczna z Regulaminem, oraz dane kontaktowe zgłaszającego. Do zgłoszenia można dołączyć załączniki.</li>
<li><strong>Rozpatrzenie.</strong> Zgłoszenia rozpatrujemy terminowo, rzetelnie, w sposób niearbitralny i obiektywny, z udziałem człowieka - decyzji nie podejmuje automat.</li>
<li><strong>Decyzja.</strong> Możliwe rozstrzygnięcia to: pozostawienie treści, wezwanie do jej poprawienia, ukrycie lub usunięcie treści oraz zablokowanie Konta w razie poważnych lub powtarzających się naruszeń. O decyzji i jej powodach informujemy zgłaszającego oraz autora treści.</li>
<li><strong>Odwołanie.</strong> Od decyzji przysługuje odwołanie w terminie 14 dni, na adres {PROVIDER.email}. Odwołanie rozpatruje osoba, która nie podejmowała decyzji pierwotnej. Użytkownik zachowuje prawo dochodzenia roszczeń przed sądem.</li>
<li><strong>Nadużycia.</strong> Zgłoszenia oczywiście bezzasadne, składane masowo lub w złej wierze, mogą pozostać bez rozpoznania.</li>
</ol>
</>
),
},
{
id: 'kontakt-miedzy-uzytkownikami',
title: '§ 8. Kontakt między Użytkownikami',
body: (
<ol>
<li>Wiadomości służą wyłącznie sprawom związanym z Ogłoszeniem. Zabronione jest wykorzystywanie ich do rozsyłania ofert handlowych niezwiązanych z Ogłoszeniem.</li>
<li>Użytkownik może zablokować rozmówcę; blokada wstrzymuje wymianę wiadomości w obie strony.</li>
<li>Termin oglądania uzgodniony w rozmowie jest ustaleniem między Użytkownikami. Usługodawca nie jest jego stroną i nie odpowiada za jego dotrzymanie.</li>
<li>Usługodawca zaleca ostrożność: nie należy przekazywać w wiadomościach danych logowania, numerów kart ani kodów autoryzacyjnych.</li>
</ol>
),
},
{
id: 'wyroznianie',
title: '§ 9. Wyróżnianie ogłoszeń (usługa odpłatna)',
body: (
<ol>
<li>Użytkownik może odpłatnie wyróżnić własne Ogłoszenie na czas określony zgodnie z planami dostępnymi w Platformie. Wyróżnione Ogłoszenia prezentowane są wyżej na listach wyników.</li>
<li>Dostępny jest także zakup pakietu wyróżnień; zakupione jednostki pozwalają wyróżnić dowolne własne Ogłoszenie.</li>
<li>Ceny podawane w Platformie są cenami brutto w złotych polskich.</li>
<li>Wyróżnienie aktywuje się po otrzymaniu potwierdzenia płatności od Operatora płatności i trwa przez wybrany okres. Zamknięcie przeglądarki po dokonaniu płatności nie wpływa na aktywację.</li>
<li>Dokument potwierdzający zakup Usługodawca wystawia na żądanie Użytkownika zgłoszone na adres {PROVIDER.email}.</li>
</ol>
),
},
{
id: 'platnosci',
title: '§ 10. Płatności',
body: (
<ol>
<li>Płatności obsługuje <strong>Autopay S.A.</strong>, ul. Powstańców Warszawy 6, 81-718 Sopot - krajowa instytucja płatnicza nadzorowana przez Komisję Nadzoru Finansowego. Dostępne metody to m.in. BLIK, karta płatnicza i szybki przelew.</li>
<li>Po wybraniu płatności Użytkownik zostaje przekierowany na stronę Operatora płatności. Usługodawca nie otrzymuje i nie przechowuje danych karty płatniczej ani danych logowania do bankowości.</li>
<li>Potwierdzenie płatności Operator przekazuje automatycznie; na jego podstawie usługa jest aktywowana.</li>
<li>W razie niepowodzenia płatności usługa nie zostaje uruchomiona, a Użytkownik może ponowić próbę. Jeżeli mimo obciążenia usługa nie została aktywowana, należy zgłosić to na adres {PROVIDER.email} - sprawa zostanie wyjaśniona z Operatorem płatności, a środki zwrócone.</li>
</ol>
),
},
{
id: 'odstapienie',
title: '§ 11. Odstąpienie od umowy (Konsument)',
body: (
<ol>
<li>Konsument może odstąpić od umowy zawartej na odległość w terminie 14 dni bez podania przyczyny.</li>
<li>Usługa wyróżnienia rozpoczyna się przed upływem tego terminu wyłącznie na wyraźne żądanie Konsumenta. Po pełnym wykonaniu usługi prawo odstąpienia wygasa, a przy wykonaniu częściowym zwrot pomniejsza się proporcjonalnie do spełnionego świadczenia (art. 35 ustawy o prawach konsumenta).</li>
<li>Oświadczenie o odstąpieniu można złożyć na adres {PROVIDER.email}. Zwrot następuje w terminie 14 dni, tym samym sposobem zapłaty.</li>
<li>Odstąpienie od umowy zawartej z innym Użytkownikiem (np. umowy najmu lub sprzedaży nieruchomości) reguluje ta umowa i przepisy prawa - Usługodawca nie jest jej stroną.</li>
</ol>
),
},
{
id: 'reklamacje',
title: '§ 12. Reklamacje',
body: (
<ol>
<li>Reklamacje dotyczące usług Platformy można składać na adres {PROVIDER.email} lub przez formularz w zakładce Pomoc i kontakt, wskazując dane Użytkownika i opis zastrzeżeń.</li>
<li>Reklamacje rozpatrujemy w terminie 14 dni i informujemy o wyniku na podany adres e-mail.</li>
<li>Usługodawca odpowiada wobec Konsumenta za zgodność usługi cyfrowej z umową na zasadach określonych w ustawie o prawach konsumenta.</li>
</ol>
),
},
{
id: 'odpowiedzialnosc',
title: '§ 13. Odpowiedzialność',
body: (
<ol>
<li>Usługodawca udostępnia narzędzia teleinformatyczne i nie jest stroną transakcji zawieranych między Użytkownikami.</li>
<li>Za treść Ogłoszeń oraz przebieg i skutki transakcji odpowiada Użytkownik, który Ogłoszenie zamieścił.</li>
<li>Usługodawca nie ma ogólnego obowiązku monitorowania treści. Po otrzymaniu wiarygodnej informacji o bezprawnym charakterze treści niezwłocznie uniemożliwia do niej dostęp, zgodnie z § 7.</li>
<li>Usługodawca dokłada starań, aby Platforma działała bez przerw, lecz nie gwarantuje nieprzerwanej dostępności i zastrzega prawo do przerw technicznych.</li>
<li>Powyższe ograniczenia nie wyłączają ani nie ograniczają praw Konsumenta w zakresie, w jakim przepisy bezwzględnie obowiązujące na to nie zezwalają.</li>
</ol>
),
},
{
id: 'blokada-konta',
title: '§ 14. Blokada i rozwiązanie umowy',
body: (
<ol>
<li>Usługodawca może zablokować Konto w razie: naruszenia Regulaminu lub prawa, zamieszczania treści bezprawnych, podszywania się pod inną osobę, działań zagrażających bezpieczeństwu Platformy albo zakładania kont w celu obejścia wcześniejszej blokady.</li>
<li>O blokadzie i jej przyczynie informujemy na adres e-mail przypisany do Konta. Od decyzji przysługuje odwołanie na zasadach z § 7 ust. 5.</li>
<li>Użytkownik może rozwiązać umowę w każdej chwili, usuwając Konto.</li>
<li>Usługodawca może rozwiązać umowę z zachowaniem 14-dniowego terminu, z ważnych przyczyn, informując o tym Użytkownika.</li>
</ol>
),
},
{
id: 'bezpieczenstwo',
title: '§ 15. Bezpieczeństwo i zgodność',
body: (
<>
<ol>
<li>Usługodawca stosuje środki techniczne i organizacyjne odpowiadające ryzyku, opisane w <Link to={ROUTES.privacy}>Polityce prywatności</Link>.</li>
<li>
Zakres stosowania przepisów sektorowych: Platforma nie jest podmiotem finansowym ani dostawcą usług ICT
dla podmiotów finansowych w rozumieniu rozporządzenia (UE) 2022/2554 (DORA), a ze względu na skalę
działalności Usługodawca nie jest podmiotem kluczowym ani ważnym w rozumieniu dyrektywy (UE) 2022/2555
(NIS2). Niezależnie od tego Usługodawca <strong>dobrowolnie stosuje praktyki bezpieczeństwa wzorowane
na wymaganiach obu aktów</strong>: zarządzanie ryzykiem teleinformatycznym, kontrolę dostępu,
szyfrowanie transmisji, ograniczanie liczby żądań oraz reagowanie na incydenty.
</li>
<li>Użytkownik zobowiązuje się nie podejmować działań zagrażających bezpieczeństwu Platformy, w szczególności prób nieuprawnionego dostępu, automatycznego pobierania danych na masową skalę ani zakłócania jej działania.</li>
<li>Podatności bezpieczeństwa prosimy zgłaszać na adres {PROVIDER.email}. Zgłoszenia dokonane w dobrej wierze nie będą podstawą roszczeń wobec zgłaszającego.</li>
</ol>
<p className="legal-note">
Platforma podlega w szczególności: rozporządzeniu (UE) 2016/679 (RODO), rozporządzeniu (UE) 2022/2065
(DSA), ustawie o świadczeniu usług drogą elektroniczną, ustawie o prawach konsumenta, Kodeksowi cywilnemu
oraz ustawie Prawo komunikacji elektronicznej.
</p>
</>
),
},
{
id: 'dane-osobowe',
title: '§ 16. Dane osobowe',
body: (
<p>
Zasady przetwarzania danych osobowych, listę odbiorców danych oraz okresy przechowywania określa
<Link to={ROUTES.privacy}> Polityka prywatności</Link>. Administratorem danych jest Usługodawca.
</p>
),
},
{
id: 'wlasnosc-intelektualna',
title: '§ 17. Własność intelektualna',
body: (
<p>
Platforma, jej układ, logotyp i oprogramowanie podlegają ochronie prawnej i stanowią własność Usługodawcy
lub podmiotów, z którymi Usługodawca współpracuje. Kopiowanie lub wykorzystywanie ich bez zgody jest
zabronione. Prawa do treści Ogłoszeń i artykułów pozostają przy ich autorach.
</p>
),
},
{
id: 'spory',
title: '§ 18. Pozasądowe rozwiązywanie sporów',
body: (
<>
<p>Konsument może skorzystać z pozasądowych sposobów rozpatrywania reklamacji i dochodzenia roszczeń, w szczególności:</p>
<ul>
<li>pomocy powiatowego (miejskiego) rzecznika konsumentów,</li>
<li>mediacji przy wojewódzkim inspektorze Inspekcji Handlowej,</li>
<li>stałego sądu polubownego przy wojewódzkim inspektorze Inspekcji Handlowej,</li>
<li>informacji i poradnictwa Urzędu Ochrony Konkurencji i Konsumentów (uokik.gov.pl).</li>
</ul>
<p className="legal-note">
Europejska platforma ODR zakończyła działalność 20 lipca 2025 r., dlatego nie wskazujemy jej jako drogi
rozwiązania sporu.
</p>
</>
),
},
{
id: 'postanowienia-koncowe',
title: '§ 19. Zmiany Regulaminu i postanowienia końcowe',
body: (
<ol>
<li>Usługodawca może zmienić Regulamin z ważnych przyczyn, w szczególności zmiany przepisów lub zakresu usług, informując Użytkowników z co najmniej 14-dniowym wyprzedzeniem na adres e-mail przypisany do Konta.</li>
<li>Użytkownik, który nie akceptuje zmian, może przed ich wejściem w życie usunąć Konto.</li>
<li>W sprawach nieuregulowanych stosuje się przepisy prawa polskiego.</li>
<li>Spory z Użytkownikiem niebędącym Konsumentem rozstrzyga sąd właściwy dla siedziby Usługodawcy.</li>
<li>Jeżeli którekolwiek postanowienie Regulaminu okaże się nieważne, pozostałe zachowują moc.</li>
</ol>
),
},
];
<h2>§ 3. Rodzaje i zakres usług</h2>
<ol>
<li>Platforma umożliwia w szczególności: przeglądanie i wyszukiwanie ogłoszeń nieruchomości, <strong>bezpłatne dodawanie ogłoszeń</strong> sprzedaży i najmu, prowadzenie Konta, zapisywanie ofert do ulubionych, ustawianie alertów cenowych, korzystanie z narzędzi (m.in. orientacyjna wycena, kalkulator zdolności), kontakt między Użytkownikami oraz odpłatne <strong>promowanie ogłoszeń</strong>.</li>
<li>Dodawanie ogłoszeń jest bezpłatne i nie wiąże się z prowizją od transakcji.</li>
<li>Usługi płatne (promowanie, pakiety promowań) są świadczone zgodnie z § 8 i cennikiem prezentowanym w Platformie.</li>
<li>Narzędzia analityczne i wyceny mają charakter wyłącznie poglądowy i nie stanowią oferty, porady ani operatu szacunkowego w rozumieniu przepisów prawa.</li>
</ol>
<h2>§ 4. Wymagania techniczne</h2>
<ol>
<li>Do korzystania z Platformy niezbędne są: urządzenie z dostępem do Internetu, aktualna przeglądarka internetowa z obsługą JavaScript i plików cookie oraz aktywne konto poczty elektronicznej.</li>
<li>Usługodawca nie ponosi odpowiedzialności za problemy techniczne leżące po stronie Użytkownika lub dostawców usług telekomunikacyjnych.</li>
</ol>
<h2>§ 5. Rejestracja i Konto</h2>
<ol>
<li>Założenie Konta wymaga wypełnienia formularza rejestracji oraz akceptacji Regulaminu i Polityki prywatności. Możliwa jest również rejestracja i logowanie za pośrednictwem kont zewnętrznych (Google, Facebook).</li>
<li>Użytkownik zobowiązuje się podać dane prawdziwe i aktualne oraz zabezpieczyć dane logowania przed dostępem osób trzecich.</li>
<li>Konto jest przypisane do jednego Użytkownika; udostępnianie go osobom trzecim jest niedozwolone.</li>
<li>Użytkownik może w każdej chwili usunąć Konto, kontaktując się z Usługodawcą lub korzystając z odpowiedniej opcji w Platformie.</li>
</ol>
<h2>§ 6. Zasady publikacji ogłoszeń</h2>
<ol>
<li>Ogłoszenie może dotyczyć wyłącznie rzeczywistej oferty sprzedaży lub najmu nieruchomości, do której Użytkownik posiada tytuł prawny lub odpowiednie umocowanie.</li>
<li>Treść Ogłoszenia powinna być zgodna ze stanem faktycznym, kompletna i nie może wprowadzać w błąd.</li>
<li>Zabronione jest zamieszczanie treści bezprawnych, w szczególności: naruszających prawa osób trzecich, obraźliwych, wulgarnych, dyskryminujących, wprowadzających w błąd, o charakterze spamu lub niezwiązanych z przedmiotem Platformy.</li>
<li>Ogłoszenia podlegają moderacji. Usługodawca może wstrzymać publikację, edytować w niezbędnym zakresie lub usunąć Ogłoszenie naruszające Regulamin lub przepisy prawa, informując o tym Użytkownika.</li>
<li>Użytkownik udziela Usługodawcy niewyłącznej, nieodpłatnej licencji na korzystanie z treści i zdjęć zamieszczonych w Ogłoszeniu w zakresie niezbędnym do świadczenia usług (prezentacja i promocja Ogłoszenia w Platformie i kanałach powiązanych).</li>
</ol>
<h2>§ 7. Odpowiedzialność</h2>
<ol>
<li>Usługodawca udostępnia narzędzia teleinformatyczne i nie jest stroną transakcji zawieranych między Użytkownikami.</li>
<li>Za treść Ogłoszeń, ich zgodność z prawem oraz przebieg i skutki transakcji odpowiada Użytkownik zamieszczający Ogłoszenie.</li>
<li>Usługodawca dokłada starań, aby Platforma działała prawidłowo, jednak nie gwarantuje nieprzerwanej dostępności i zastrzega prawo do przerw technicznych.</li>
<li>Usługodawca działa zgodnie z ustawą o świadczeniu usług drogą elektroniczną i nie ma obowiązku uprzedniej kontroli treści; po uzyskaniu wiarygodnej wiadomości o bezprawnym charakterze treści niezwłocznie uniemożliwia do niej dostęp.</li>
</ol>
<h2>§ 8. Promowanie ogłoszeń (usługi płatne)</h2>
<ol>
<li>Użytkownik może odpłatnie wyróżnić Ogłoszenie na czas określony (np. 4, 14 lub 30 dni) zgodnie z planami dostępnymi w Platformie. Wyróżnione Ogłoszenia prezentowane są wyżej na listach wyników.</li>
<li>Dostępny jest również zakup pakietu promowań (np. 5 lub 10 sztuk) w cenie promocyjnej; zakupione kredyty pozwalają wyróżnić dowolne własne Ogłoszenie zgodnie z wybranym planem czasowym.</li>
<li>Ceny prezentowane w Platformie są cenami brutto wyrażonymi w złotych polskich (PLN).</li>
<li>Usługa promowania rozpoczyna się niezwłocznie po zaksięgowaniu płatności i trwa przez wybrany okres.</li>
</ol>
<h2>§ 9. Płatności</h2>
<ol>
<li>Płatności za usługi płatne obsługiwane są przez zewnętrznego Operatora płatności (AutoPay - Autopay S.A.), umożliwiającego m.in. płatność BLIK, kartą płatniczą oraz szybkim przelewem.</li>
<li>Rozliczenie następuje w momencie zakupu. Na żądanie Użytkownika Usługodawca wystawia dokument potwierdzający zakup zgodnie z obowiązującymi przepisami.</li>
<li>Usługodawca nie przechowuje pełnych danych kart płatniczych - są one przetwarzane wyłącznie przez Operatora płatności w bezpiecznym środowisku.</li>
</ol>
<h2>§ 10. Odstąpienie od umowy (Konsument)</h2>
<ol>
<li>Konsument, który zawarł umowę na odległość, może od niej odstąpić bez podania przyczyny w terminie 14 dni.</li>
<li>Rozpoczęcie świadczenia usługi promowania przed upływem terminu na odstąpienie następuje na wyraźne żądanie Konsumenta; Konsument przyjmuje do wiadomości, że po pełnym wykonaniu usługi traci prawo odstąpienia, a przy częściowym wykonaniu zwrot pomniejsza się proporcjonalnie do spełnionego świadczenia (art. 35 ustawy o prawach konsumenta).</li>
<li>Oświadczenie o odstąpieniu można złożyć na adres e-mail: {PROVIDER.email}.</li>
</ol>
<h2>§ 11. Reklamacje</h2>
<ol>
<li>Reklamacje dotyczące usług można składać na adres e-mail {PROVIDER.email}, wskazując dane Użytkownika i opis zastrzeżeń.</li>
<li>Usługodawca rozpatruje reklamację w terminie do 14 dni i informuje Użytkownika o wyniku.</li>
</ol>
<h2>§ 12. Dane osobowe</h2>
<p>Zasady przetwarzania danych osobowych określa <Link to={ROUTES.privacy}>Polityka prywatności</Link>. Administratorem danych jest Usługodawca.</p>
<h2>§ 13. Pliki cookie</h2>
<p>Platforma wykorzystuje pliki cookie zgodnie z Polityką prywatności. Podczas pierwszej wizyty Użytkownik wyraża zgodę na wykorzystanie plików cookie lub ogranicza ją do plików niezbędnych.</p>
<h2>§ 14. Prawa autorskie</h2>
<p>Platforma, jej układ, logotyp oraz oprogramowanie podlegają ochronie prawnej i stanowią własność Usługodawcy lub podmiotów, z którymi Usługodawca współpracuje. Kopiowanie lub wykorzystywanie bez zgody jest zabronione.</p>
<h2>§ 15. Pozasądowe rozwiązywanie sporów</h2>
<p>Konsument może skorzystać z pozasądowych sposobów rozpatrywania reklamacji i dochodzenia roszczeń, w tym z platformy ODR Komisji Europejskiej dostępnej pod adresem ec.europa.eu/consumers/odr oraz z pomocy powiatowego (miejskiego) rzecznika konsumentów.</p>
<h2>§ 16. Zmiany Regulaminu i postanowienia końcowe</h2>
<ol>
<li>Usługodawca może zmienić Regulamin z ważnych przyczyn (m.in. zmiana przepisów, zakresu usług), informując Użytkowników z odpowiednim wyprzedzeniem.</li>
<li>W sprawach nieuregulowanych stosuje się przepisy prawa polskiego, w szczególności Kodeksu cywilnego, ustawy o świadczeniu usług drogą elektroniczną oraz ustawy o prawach konsumenta.</li>
<li>Regulamin obowiązuje od dnia publikacji.</li>
</ol>
</LegalPage>
return (
<LegalDocument
title="Regulamin"
subtitle="Zasady korzystania z platformy ogłoszeniowej Polska Lokalnie i świadczenia usług drogą elektroniczną."
intro={(
<p>
Regulamin opisuje funkcje, które Platforma rzeczywiście udostępnia, oraz zasady odpowiedzialności za treści
publikowane przez Użytkowników. Dodawanie ogłoszeń jest bezpłatne i bez prowizji; odpłatne jest wyłącznie
dobrowolne wyróżnianie ofert.
</p>
)}
sections={sections}
/>
);
}
export function PrivacyPolicyPage() {
const sections: LegalSection[] = [
{
id: 'administrator',
title: '1. Administrator danych',
body: (
<>
<p>Administratorem danych osobowych jest:</p>
<ProviderDetails />
<p>
We wszystkich sprawach dotyczących danych osobowych prosimy o kontakt na adres {PROVIDER.email}.
Administrator nie wyznaczył inspektora ochrony danych - obowiązek taki nie wynika z art. 37 RODO.
</p>
</>
),
},
{
id: 'jakie-dane',
title: '2. Jakie dane przetwarzamy',
body: (
<ul>
<li><strong>Dane konta</strong> - adres e-mail, hasło w postaci zaszyfrowanej (funkcja skrótu), imię i nazwisko lub nazwa firmy, nazwa użytkownika oraz opcjonalnie numer telefonu. Przy koncie firmowym dodatkowo NIP i adres siedziby. Od osób prywatnych nie zbieramy adresu zamieszkania ani daty urodzenia.</li>
<li><strong>Dane profilu</strong> - opis, zdjęcie profilowe i tło, adres strony i profili społecznościowych, preferencje wyszukiwania - jeśli Użytkownik je poda.</li>
<li><strong>Treści</strong> - ogłoszenia wraz ze zdjęciami i lokalizacją, artykuły w poradnikach, wiadomości do innych Użytkowników i do obsługi serwisu.</li>
<li><strong>Aktywność na koncie</strong> - ulubione ogłoszenia, zapisane wyszukiwania, alerty cenowe, umówione terminy oglądania, powiadomienia.</li>
<li><strong>Zgłoszenia i moderacja</strong> - treść zgłoszenia, załączniki oraz - przy zgłoszeniu Użytkownika - zapis rozmowy, której zgłoszenie dotyczy.</li>
<li><strong>Płatności</strong> - identyfikator zamówienia, kwota, metoda i status płatności oraz adres e-mail płatnika. Nie otrzymujemy danych karty płatniczej.</li>
<li><strong>Dane techniczne</strong> - adres IP, adres żądania, typ przeglądarki i strona odsyłająca, zapisywane w logach serwera.</li>
<li><strong>Dane firmowe z rejestru</strong> - przy zakładaniu konta firmowego dane pobierane z rejestru REGON na podstawie podanego numeru NIP.</li>
</ul>
),
},
{
id: 'cele-i-podstawy',
title: '3. Cele i podstawy prawne',
body: (
<ul>
<li><strong>Prowadzenie konta i świadczenie usług</strong> (ogłoszenia, wiadomości, ulubione, alerty, terminy oglądania) - art. 6 ust. 1 lit. b RODO, czyli wykonanie umowy.</li>
<li><strong>Obsługa płatności za wyróżnienia i rozliczenia</strong> - art. 6 ust. 1 lit. b i c RODO.</li>
<li><strong>Moderacja treści i rozpatrywanie zgłoszeń</strong> - art. 6 ust. 1 lit. c RODO (obowiązki z aktu o usługach cyfrowych) oraz art. 6 ust. 1 lit. f RODO.</li>
<li><strong>Bezpieczeństwo Platformy i zapobieganie nadużyciom</strong> - art. 6 ust. 1 lit. f RODO, czyli nasz prawnie uzasadniony interes.</li>
<li><strong>Obrona przed roszczeniami i ich dochodzenie</strong> - art. 6 ust. 1 lit. f RODO.</li>
<li><strong>Obowiązki rachunkowe i podatkowe</strong> - art. 6 ust. 1 lit. c RODO.</li>
<li><strong>Informacje marketingowe</strong> (wiadomości o nowościach i ofertach) - wyłącznie na podstawie odrębnej zgody, art. 6 ust. 1 lit. a RODO. Zgody nie zakładamy z góry: domyślnie jest wyłączona, a Użytkownik włącza ją sam w ustawieniach konta i może wycofać w każdej chwili.</li>
</ul>
),
},
{
id: 'czy-obowiazkowe',
title: '4. Czy podanie danych jest obowiązkowe',
body: (
<ul>
<li>Adres e-mail, hasło oraz imię i nazwisko lub nazwa są niezbędne do założenia konta - bez nich nie możemy świadczyć usługi.</li>
<li>NIP jest niezbędny przy koncie firmowym.</li>
<li>Numer telefonu, adres siedziby firmy, opis profilu i zdjęcia są dobrowolne. Ich brak ogranicza jedynie wybrane funkcje (np. kontakt telefoniczny z ogłoszenia).</li>
<li>Zbieramy wyłącznie dane, które są potrzebne do działania konkretnej funkcji serwisu. Pól „na wszelki wypadek" nie ma - jeżeli dane nie mają zastosowania, usuwamy je z formularzy i z bazy.</li>
<li>Przeglądanie ogłoszeń nie wymaga zakładania konta.</li>
</ul>
),
},
{
id: 'odbiorcy',
title: '5. Odbiorcy danych',
body: (
<>
<p>Dane powierzamy wyłącznie podmiotom, które przetwarzają je na nasze zlecenie, na podstawie umów powierzenia:</p>
<ul>
<li><strong>{PROVIDER.hosting}</strong> - utrzymanie serwerów w {PROVIDER.dataCenter}.</li>
<li><strong>Dostawca poczty wychodzącej (SMTP)</strong> - wysyłka wiadomości systemowych, m.in. linku aktywacyjnego i powiadomień o wiadomościach.</li>
<li><strong>Bramka SMS</strong> - wysyłka kodów potwierdzających numer telefonu.</li>
</ul>
<p>Odrębnymi administratorami danych są natomiast:</p>
<ul>
<li><strong>Autopay S.A.</strong> - w zakresie realizacji płatności za wyróżnienia.</li>
<li><strong>Drugi Użytkownik</strong> - dane, które sam udostępniasz w ogłoszeniu lub w wiadomości (np. imię, numer telefonu, ustalony termin oglądania), trafiają do osoby, z którą się kontaktujesz.</li>
</ul>
<p>Wyświetlenie niektórych elementów strony powoduje połączenie Twojej przeglądarki z serwerami dostawców zewnętrznych, którzy otrzymują wówczas Twój adres IP:</p>
<ul>
<li><strong>Esri (ArcGIS Online)</strong> oraz <strong>OpenStreetMap</strong> - kafelki map. Mapa jest niezbędna do przeglądania ofert i ładuje się zawsze.</li>
<li><strong>OpenStreetMap (Nominatim)</strong> - podpowiedzi adresów i ustalanie współrzędnych wpisanej lokalizacji. Do usługi trafia wpisywany adres.</li>
<li><strong>flagcdn.com</strong> - obrazki flag przy wyborze kierunkowego numeru telefonu.</li>
<li><strong>YouTube (tryb bez ciasteczek), Matterport, Kuula</strong> - wirtualne spacery osadzone w ogłoszeniach. Ładują się <strong>dopiero po Twoim kliknięciu</strong>; do tego czasu w ich miejscu widnieje zastępnik.</li>
</ul>
<p>
Dane mogą być także udostępnione organom publicznym, gdy obowiązek taki wynika z przepisów prawa.
Nie sprzedajemy danych i nie udostępniamy ich partnerom marketingowym.
</p>
</>
),
},
{
id: 'poza-eog',
title: '6. Przekazywanie danych poza EOG',
body: (
<>
<p>
Dane przechowujemy na serwerach w Polsce. Poza Europejski Obszar Gospodarczy trafia jedynie adres IP
i informacje techniczne przeglądarki, w związku z wyświetlaniem elementów opisanych w punkcie 5:
</p>
<ul>
<li>kafelki map dostarcza Esri z siedzibą w Stanach Zjednoczonych,</li>
<li>wirtualne spacery - YouTube (Google), Matterport i Kuula, również ze Stanów Zjednoczonych, przy czym wyłącznie po Twoim kliknięciu.</li>
</ul>
<p>
Podstawą przekazania są mechanizmy przewidziane w rozdziale V RODO: decyzja Komisji Europejskiej
stwierdzająca odpowiedni stopień ochrony (Data Privacy Framework) albo standardowe klauzule umowne.
</p>
</>
),
},
{
id: 'okresy',
title: '7. Jak długo przechowujemy dane',
body: (
<ul>
<li><strong>Dane konta i treści</strong> - przez czas posiadania konta. Po jego usunięciu kasujemy: wiadomości i rozmowy, ogłoszenia, ulubione, zapisane wyszukiwania, alerty cenowe, terminy oglądania, powiadomienia, artykuły, dane profilu oraz wpis w bazie marketingowej.</li>
<li><strong>Dane rozliczeniowe</strong> (zamówienia wyróżnień) - 5 lat od końca roku, w którym powstał obowiązek podatkowy.</li>
<li><strong>Zgłoszenia treści i korespondencja z obsługą</strong> - do 3 lat, w celu obrony przed roszczeniami i wykazania prawidłowej moderacji. Po usunięciu konta zgłoszenia pozostają w zakresie niezbędnym do tego celu.</li>
<li><strong>Jednorazowe tokeny i kody SMS</strong> - usuwane automatycznie po 7 dniach od wygaśnięcia.</li>
<li><strong>Logi serwera</strong> - przechowywane w buforze o ograniczonym rozmiarze i nadpisywane rotacyjnie; nie tworzymy z nich archiwum.</li>
<li><strong>Dane przetwarzane na podstawie zgody</strong> - do czasu jej wycofania.</li>
</ul>
),
},
{
id: 'prawa',
title: '8. Twoje prawa',
body: (
<>
<p>Przysługuje Ci prawo do:</p>
<ul>
<li>dostępu do danych i otrzymania ich kopii,</li>
<li>sprostowania danych - większość zmienisz samodzielnie w ustawieniach konta,</li>
<li>usunięcia danych - konto usuniesz samodzielnie w panelu konta,</li>
<li>ograniczenia przetwarzania,</li>
<li>przenoszenia danych przetwarzanych na podstawie umowy lub zgody,</li>
<li><strong>sprzeciwu</strong> wobec przetwarzania opartego na prawnie uzasadnionym interesie,</li>
<li>wycofania zgody w dowolnym momencie, bez wpływu na zgodność z prawem przetwarzania sprzed wycofania.</li>
</ul>
<p>
Przysługuje Ci także prawo wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych,
ul. Stawki 2, 00-193 Warszawa, uodo.gov.pl.
</p>
</>
),
},
{
id: 'profilowanie',
title: '9. Profilowanie i decyzje automatyczne',
body: (
<p>
Nie profilujemy Użytkowników i nie podejmujemy wobec nich decyzji wyłącznie w sposób zautomatyzowany,
które wywoływałyby skutki prawne lub podobnie istotnie na nich wpływały (art. 22 RODO). Alerty cenowe
porównują wyłącznie liczby - cenę ogłoszenia lub średnią cenę za m² - i nie budują profilu osoby.
Decyzje moderacyjne podejmuje człowiek; automat jedynie oznacza treści do sprawdzenia.
</p>
),
},
{
id: 'pamiec-przegladarki',
title: '10. Pamięć przeglądarki i pliki cookie',
body: (
<>
<p>
Platforma <strong>nie ustawia plików cookie</strong> i nie korzysta z narzędzi analitycznych ani
pikseli reklamowych. Dane zapisujemy w pamięci przeglądarki (localStorage i sessionStorage):
</p>
<div className="legal-table-wrap">
<table className="legal-table">
<thead>
<tr><th>Nazwa</th><th>Do czego służy</th><th>Jak długo</th></tr>
</thead>
<tbody>
<tr>
<td>polskalokalnie-auth-token</td>
<td>utrzymanie zalogowania (niezbędne)</td>
<td>24 godziny</td>
</tr>
<tr>
<td>polskalokalnie-cookie-consent</td>
<td>zapamiętanie Twojego wyboru w banerze</td>
<td>do wyczyszczenia danych strony</td>
</tr>
<tr>
<td>polskalokalnie.valuationDraft</td>
<td>roboczy formularz wyceny, żeby nie ginął przy odświeżeniu</td>
<td>do zamknięcia karty</td>
</tr>
<tr>
<td>accountProfile:&lt;konto&gt;:…</td>
<td>drobne wygody interfejsu przypisane do konta: ostatnio oglądane oferty, wybrana zakładka, układ kafelków panelu</td>
<td>do wyczyszczenia danych strony</td>
</tr>
</tbody>
</table>
</div>
<p>
Wybór w banerze decyduje o ładowaniu treści osadzanych z serwisów zewnętrznych (wirtualne spacery).
Możesz go zmienić w każdej chwili, klikając „Ustawienia prywatności" w stopce. Osadzony spacer możesz
też włączyć pojedynczo, przyciskiem przy konkretnym ogłoszeniu.
</p>
</>
),
},
{
id: 'bezpieczenstwo-danych',
title: '11. Bezpieczeństwo danych',
body: (
<>
<p>Stosujemy środki techniczne i organizacyjne odpowiadające ryzyku, w szczególności:</p>
<ul>
<li>szyfrowanie transmisji (HTTPS z wymuszonym HSTS),</li>
<li>przechowywanie haseł wyłącznie w postaci skrótu (bcrypt) - nie znamy Twojego hasła,</li>
<li>kontrolę dostępu i rozdzielenie uprawnień w panelu administracyjnym,</li>
<li>ograniczanie liczby żądań, chroniące przed atakami automatycznymi i nadużyciami,</li>
<li>nagłówki bezpieczeństwa przeglądarki oraz oczyszczanie treści publikowanych przez Użytkowników,</li>
<li>bieżącą aktualizację oprogramowania i przeglądy bezpieczeństwa.</li>
</ul>
<p>
Praktyki te wzorujemy na wymaganiach dyrektywy NIS2 oraz na zasadach zarządzania ryzykiem
teleinformatycznym znanych z rozporządzenia DORA, choć - ze względu na charakter i skalę działalności -
Platforma nie jest podmiotem objętym obowiązkami żadnego z tych aktów.
</p>
</>
),
},
{
id: 'zmiany',
title: '12. Zmiany Polityki prywatności',
body: (
<p>
Politykę aktualizujemy, gdy zmienia się sposób działania Platformy. Aktualna wersja jest zawsze dostępna
pod tym adresem wraz z datą obowiązywania. O istotnych zmianach informujemy na adres e-mail przypisany
do konta.
</p>
),
},
];
return (
<LegalPage
<LegalDocument
title="Polityka prywatności"
subtitle="Informacje o przetwarzaniu danych osobowych oraz wykorzystaniu plików cookie na platformie Polska Lokalnie."
>
<h2>1. Administrator danych</h2>
<p>Administratorem danych osobowych jest <strong>{PROVIDER.legal}</strong>, {PROVIDER.address}, NIP: {PROVIDER.nip}. Kontakt w sprawach danych osobowych: {PROVIDER.email}.</p>
<h2>2. Jakie dane przetwarzamy</h2>
<ul>
<li>dane podane przy rejestracji i w profilu (imię i nazwisko lub nazwa, adres e-mail, numer telefonu, dane firmowe),</li>
<li>dane zawarte w ogłoszeniach i wiadomościach,</li>
<li>dane dotyczące płatności za usługi płatne (bez pełnych danych kart płatniczych),</li>
<li>dane techniczne i o aktywności (adres IP, identyfikatory urządzenia, pliki cookie, logi).</li>
</ul>
<h2>3. Cele i podstawy prawne</h2>
<ul>
<li>świadczenie usług i prowadzenie Konta - art. 6 ust. 1 lit. b RODO (wykonanie umowy),</li>
<li>obsługa płatności i rozliczeń - art. 6 ust. 1 lit. b i c RODO,</li>
<li>obowiązki prawne (m.in. rachunkowe, reklamacje) - art. 6 ust. 1 lit. c RODO,</li>
<li>marketing własny, statystyki i bezpieczeństwo - art. 6 ust. 1 lit. f RODO (prawnie uzasadniony interes),</li>
<li>działania marketingowe realizowane na podstawie zgody (np. newsletter) - art. 6 ust. 1 lit. a RODO.</li>
</ul>
<h2>4. Odbiorcy danych</h2>
<p>Dane mogą być przekazywane podmiotom przetwarzającym je na zlecenie Administratora, w szczególności: dostawcy hostingu {PROVIDER.hosting}, Operatorowi płatności AutoPay (Autopay S.A.) w zakresie realizacji płatności, dostawcom usług e-mail i SMS oraz podmiotom świadczącym wsparcie techniczne - wyłącznie w zakresie niezbędnym i na podstawie odpowiednich umów powierzenia.</p>
<h2>5. Okres przechowywania</h2>
<p>Dane przechowujemy przez czas trwania umowy (posiadania Konta), a po jej zakończeniu przez okres wymagany przepisami (m.in. rozliczeniowymi) lub do przedawnienia roszczeń. Dane przetwarzane na podstawie zgody - do jej wycofania.</p>
<h2>6. Prawa osób, których dane dotyczą</h2>
<p>Przysługuje Państwu prawo dostępu do danych, ich sprostowania, usunięcia, ograniczenia przetwarzania, przenoszenia, wniesienia sprzeciwu oraz cofnięcia zgody w dowolnym momencie (bez wpływu na zgodność z prawem wcześniejszego przetwarzania). Mają Państwo także prawo wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych (ul. Stawki 2, 00-193 Warszawa).</p>
<h2>7. Przekazywanie poza EOG</h2>
<p>Dane są przetwarzane na terenie Europejskiego Obszaru Gospodarczego, a serwery Platformy znajdują się fizycznie na terytorium Rzeczypospolitej Polskiej. W razie przekazania danych poza EOG stosujemy odpowiednie zabezpieczenia (m.in. standardowe klauzule umowne).</p>
<h2>8. Pliki cookie</h2>
<ul>
<li><strong>Niezbędne</strong> - konieczne do działania Platformy (logowanie, sesja, bezpieczeństwo). Nie wymagają zgody.</li>
<li><strong>Analityczne</strong> - pomagają zrozumieć sposób korzystania z Platformy i go ulepszać.</li>
<li><strong>Marketingowe/funkcjonalne</strong> - umożliwiają dopasowanie treści i zapamiętanie preferencji.</li>
</ul>
<p>Zgodę na pliki inne niż niezbędne wyrażają Państwo w banerze cookie podczas pierwszej wizyty; można ją w każdej chwili zmienić w ustawieniach przeglądarki. Ograniczenie plików cookie może wpłynąć na dostępność części funkcji.</p>
<h2>9. Bezpieczeństwo</h2>
<p>Stosujemy środki techniczne i organizacyjne odpowiadające ryzyku, w tym szyfrowanie transmisji (SSL/TLS) oraz kontrolę dostępu do danych.</p>
<h2>10. Zmiany polityki</h2>
<p>Polityka może być aktualizowana. Aktualna wersja jest zawsze dostępna w Platformie wraz z datą ostatniej aktualizacji.</p>
</LegalPage>
subtitle="Jakie dane zbieramy, po co, komu je powierzamy i jak długo je przechowujemy."
intro={(
<p>
Ten dokument opisuje rzeczywisty sposób działania serwisu. Nie używamy narzędzi analitycznych ani pikseli
reklamowych, nie profilujemy Użytkowników i nie wysyłamy wiadomości marketingowych bez wyraźnej zgody.
</p>
)}
sections={sections}
/>
);
}
const COOKIE_CONSENT_KEY = 'polskalokalnie-cookie-consent';
/**
* Czy uzytkownik zgodzil sie na tresci osadzane z serwisow zewnetrznych (wirtualne spacery
* z YouTube, Matterport, Kuula). Takie osadzenie przekazuje dostawcy adres IP uzytkownika,
* wiec bez zgody pokazujemy zastepnik z przyciskiem zamiast ladowac ramke.
*
* Mapy sa traktowane jako element niezbedny do ogladania ogloszen i nie zaleza od tej zgody.
*/
export function hasExternalEmbedConsent(): boolean {
try {
return window.localStorage.getItem(COOKIE_CONSENT_KEY) === 'all';
} catch {
return false;
}
}
const COOKIE_OPEN_EVENT = 'pl-cookie-open';
// Ponowne otwarcie ustawień plików cookie (np. z linku w stopce).
// Ponowne otwarcie ustawień prywatności (np. z linku w stopce).
export function openCookieSettings() {
try {
window.localStorage.removeItem(COOKIE_CONSENT_KEY);
@@ -217,7 +678,7 @@ export function openCookieSettings() {
window.dispatchEvent(new Event(COOKIE_OPEN_EVENT));
}
// Baner zgody na pliki cookie - pojawia się przy pierwszej wizycie, dopóki Użytkownik nie dokona wyboru.
// Baner zgody - pojawia się przy pierwszej wizycie, dopóki Użytkownik nie dokona wyboru.
export function CookieBanner() {
const [visible, setVisible] = useState(false);
@@ -248,14 +709,15 @@ export function CookieBanner() {
}
return (
<div className="cookie-banner" role="dialog" aria-live="polite" aria-label="Zgoda na pliki cookie">
<div className="cookie-banner" role="dialog" aria-live="polite" aria-label="Ustawienia prywatności">
<div className="cookie-banner-inner">
<div className="cookie-banner-text">
<strong>Dbamy o Twoją prywatność</strong>
<p>
Używamy plików cookie, aby platforma działała poprawnie, oraz - za Twoją zgodą - do celów analitycznych
i marketingowych. Szczegóły znajdziesz w <Link to={ROUTES.privacy}>Polityce prywatności</Link> i{' '}
<Link to={ROUTES.terms}>Regulaminie</Link>.
Nie używamy narzędzi analitycznych ani reklamowych. Zapisujemy tylko dane potrzebne do działania serwisu.
Twoja zgoda dotyczy wyłącznie treści osadzanych z serwisów zewnętrznych - wirtualnych spacerów
(YouTube, Matterport, Kuula), które otrzymują wtedy Twój adres IP. Szczegóły w{' '}
<Link to={ROUTES.privacy}>Polityce prywatności</Link> i <Link to={ROUTES.terms}>Regulaminie</Link>.
</p>
</div>
<div className="cookie-banner-actions">

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