Compare commits

..

12 Commits

Author SHA1 Message Date
pascal 0ec73959a7 Merge branch 'pascal-zmiany' 2026-07-24 00:42:09 +02:00
pascal 3e394998a3 Strona /firmy, CTA jako linki i testy Playwright routingu
Nowa trasa:
- /firmy - lista firm i uslug na istniejacych komponentach (.directory-card)
  i stylistyce sekcji uslug; filtr kategorii w query stringu (/firmy?kategoria=remonty)
- serviceCategories dostaly slug, nowa baza companyDirectory pokrywa wszystkie
  8 kategorii, wiec filtrowanie dziala na realnych danych
- "Zobacz firmy" prowadzi do listy z wybrana kategoria, "Zobacz wszystkie"
  do pelnej listy - ostatnie dwie atrapy href="#" usuniete

CTA zamienione na <Link> (wylacznie czysta nawigacja, bez akcji):
- 35 przyciskow onClick={() => navigate(x)} zamienionych automatycznie
- 5 skrotow panelu admina (setTab) -> Link do /admin/*
- karty narzedzi konta i kafelek poradnika: zamiast role="button" prawdziwy link
- pozycje menu konta i popovera powiadomien -> Link + zamkniecie popovera
- karty ofert: link rozciagniety na cala karte (.card-stretched-link) zamiast
  role="button", bez zagniezdzania przyciskow w <a>
- bez zmian zostaly przyciski wykonujace akcje: publikacja, zapis draftu,
  wyszukiwanie, oznaczanie powiadomien, bramki logowania

Wyglad 1:1 - rozszerzone selektory kontenerow o warianty <a>, reset :where(a)
o zerowej specyficznosci oraz odtworzenie centrowania tam, gdzie <button>
robil to domyslnie. Zweryfikowane zrzutami ekranu i pomiarem geometrii.

Naprawa: kazdy blad /auth/me (takze chwilowy timeout albo 5xx) kasowal token
i wylogowywal uzytkownika. Teraz sesje konczy tylko odrzucenie tokenu (401/403),
pozostale bledy sa ponawiane raz.

Testy: frontend/tests/routing.spec.ts (41 testow) + playwright.config.ts,
skrypt npm run test:e2e. E2E_BASE_URL pozwala uruchomic je na buildzie
produkcyjnym za nginx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 00:37:04 +02:00
pascal e1ab61ec7c Standard routingu w AGENTS.md i usuniecie atrap href="#"
- AGENTS.md: obowiazkowy standard architektoniczny - kazdy widok ma wlasny
  URL w React Routerze, zakaz nawigacji na useState/setView/activeView,
  identyfikatory w parametrach URL, filtry w query string, ProtectedRoute
- 7 z 9 atrap href="#" zamienionych na <Link> do istniejacych tras
- pozostaly 2 w ServicesPage ("Zobacz firmy", "Zobacz wszystkie") - brak
  docelowej strony listy firm, wymagaja decyzji produktowej

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:58:22 +02:00
pascal f966b4539a Okruszki nawigacyjne i CTA naglowka jako <Link>
- 16 przyciskow w breadcrumbach zamienionych na <Link> (semantyka linku,
  otwieranie w nowej karcie, podglad celu na hover)
- naglowek: "Moje konto" i "Dodaj ogloszenie za darmo" jako <Link>
- CSS breadcrumbow rozszerzony o warianty <a>, wyglad bez zmian
  (zweryfikowane zrzutami ekranu)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:52:37 +02:00
pascal 5f5bec7232 Przewijanie przy zmianie trasy i naprawa petli renderow w porownaniu ofert
- nowa trasa startuje od gory strony, przy cofaniu (POP) pozycja zostaje
  przy przegladarce, wiec powrot do listy ofert wraca w to samo miejsce
- AccountComparisonPage: setSelectedDetails({}) tworzylo nowy obiekt przy
  kazdym przebiegu efektu zaleznego od selectedDetails, co dawalo
  nieskonczona petle ("Maximum update depth exceeded"). Blad byl obecny
  przed migracja - ujawnily go testy E2E nowej trasy /konto/porownanie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:48:43 +02:00
pascal af21991d9f Panel admina: 16 zakladek jako podsciezki /admin/*
- aktywna zakladka wyznaczana z adresu (useParams), nie z useState
- setTab() nawiguje zamiast zmieniac stan lokalny
- sidebar admina: <button> -> <NavLink>
- typ AdminTab zunifikowany z AdminTabKey z routes.ts
- nieznana sekcja /admin/cokolwiek -> strona 404
- CSS .admin-nav rozszerzony o warianty <a>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:36:30 +02:00
pascal ec5a7289f6 Zastap useState(view) routingiem opartym o react-router-dom
Rdzen migracji - kazdy widok ma wlasny adres URL:
- render {view === '...'} zastapiony przez <Routes> z 31 trasami
- usuniete: type View, PROTECTED_VIEWS, proteza #login, stan view/mapCity/
  mapOfferType/selectedListingId/messageThreadTarget
- prop onNavigate (166 wystapien) zastapiony hookiem useNavigate w komponentach
- /oferta/:id czyta identyfikator z adresu (ListingDetailRoute)
- /mapa?miasto=&typ= czyta parametry z query stringa (MapSearchRoute)
- dane przejsciowe miedzy trasami (watek wiadomosci, oferta do negocjacji)
  przekazywane w location.state zamiast globalnego stanu
- ProtectedRoute na trasach konta, requireAdmin na /admin
- powrot do zamierzonego celu po zalogowaniu (location.state.from)
- Header i AccountSidebar: <button> -> <NavLink>, aktywnosc z adresu
- strona 404 dla nieznanych adresow
- CSS: selektory menu rozszerzone o warianty <a> (wyglad bez zmian)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:34:35 +02:00
pascal f072ed0115 Dodaj react-router-dom, mape tras i ProtectedRoute
Fundament migracji z useState(view) na prawdziwy routing:
- react-router-dom 7 w zaleznosciach
- BrowserRouter w main.tsx (nginx ma juz try_files, wiec czyste sciezki dzialaja)
- routes.ts: centralna mapa adresow + helpery listingPath/mapPath/adminTabPath
- ProtectedRoute z obsluga stanu loading z AuthProvider

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 23:24:22 +02:00
pascal a6b156695f sms & SMTP 2026-07-23 23:15:15 +02:00
pascal 12fcb60b4c Kalkulator zdolności, integracja Moich ofert i system powiadomień
- Kalkulator zdolności kredytowej: nowy widok z live-obliczeniami (rata
  annuitetowa/malejąca, DTI/DSTI, rekomendacje, donut), wpięty w poradnik,
  panel i stronę główną; pasek boczny konta dla zalogowanych.
- Moje oferty: prawdziwe ogłoszenia (/listings/mine) scalone z listą,
  realne akcje Wstrzymaj/Wznów (PATCH /status, nowy status PAUSED),
  Usuń (DELETE) z kontrolą właściciela; realne wyświetlenia (viewsCount)
  i zapisane z ulubionych. Fix CHECK constraintu enuma w SchemaFixer.
- System powiadomień: encja Notification + serwis + kontroler + SSE
  realtime (token w query param w JwtAuthFilter). Podpięte źródła
  serwerowe (wiadomości, zmiana statusu ogłoszenia, zdarzenia konta)
  i klienckie (ulubione, spadek ceny, spotkania, weryfikacja telefonu).
  Centralny NotificationsProvider; dzwonek i strona bez zmian designu,
  klikalne z deep-linkami i oznaczaniem jako przeczytane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:02:25 +02:00
pascal e9673551ce dodanie poradnika 2026-07-20 02:06:11 +02:00
pascal 498ea07b9e Merge pull request 'Pascal zmiany Alerty cenowe + Pomoc i kontakt' (#1) from pascal-zmiany into main
Reviewed-on: #1
2026-07-13 01:22:20 +02:00
84 changed files with 9584 additions and 590 deletions
+2
View File
@@ -10,6 +10,8 @@ backend/target/
frontend/node_modules/
frontend/dist/
frontend/.vite/
frontend/test-results/
frontend/playwright-report/
# IDE
.idea/
+25
View File
@@ -44,6 +44,31 @@ Pull requests should include a short summary, testing performed, linked issues w
Do not commit secrets, local credentials, generated build output, or machine-specific configuration. Store required environment variables in an ignored `.env` file and document safe example values in `.env.example`.
## Standard architektoniczny: routing (obowiązkowy)
Każdy nowy widok, ekran, zakładka lub podstrona **musi mieć własny, jednoznaczny adres URL** i być obsługiwana przez React Router. To twardy standard całego projektu, nie sugestia.
Zasady:
- **Nie buduj nawigacji na stanie.** Zakazane jako mechanizm przełączania stron: `useState('view')`, `setView`, `activeView`, `selectedPage`, `selectedTab` i pochodne.
- **Nie przełączaj całych stron warunkowym renderowaniem** bez zmiany adresu (`{view === 'x' && <Page />}`).
- **Do nawigacji używaj** `Link`, `NavLink`, `useNavigate` oraz tras zadeklarowanych w `Routes`.
- **Identyfikatory obiektów trzymaj w parametrach URL**: `/oferta/:id`, `/profil/:id`, `/wiadomosci/:conversationId` — nie w stanie Reacta.
- **Filtry, sortowanie, wyszukiwanie i paginację zapisuj w query string** wszędzie tam, gdzie użytkownik powinien móc skopiować lub odświeżyć widok.
- **Zakładki paneli twórz jako trasy zagnieżdżone**: `/konto/ustawienia`, `/admin/uzytkownicy`.
- **Trasy wymagające logowania zabezpieczaj `ProtectedRoute`** (`frontend/src/ProtectedRoute.tsx`). Pamiętaj, że to zabezpieczenie interfejsu — autoryzację egzekwuje backend.
- **Nie używaj `href="#"`** ani przycisków imitujących linki.
- **Każdy link musi działać** po odświeżeniu (F5), przy bezpośrednim wejściu z adresu, po użyciu Wstecz/Dalej i po otwarciu w nowej karcie.
Gdzie co leży:
- `frontend/src/routes.ts` — centralna mapa adresów (`ROUTES`) i helpery (`listingPath`, `mapPath`, `adminTabPath`). Nowy widok zaczynaj od wpisu tutaj, nie od literału ścieżki w JSX.
- `frontend/src/ProtectedRoute.tsx` — bramka autoryzacji, obsługuje stan `loading` z `AuthProvider`.
- `frontend/src/App.tsx` — deklaracja `<Routes>`.
- `nginx/default.conf``try_files $uri $uri/ /index.html` (SPA fallback, już skonfigurowany).
Przed zakończeniem każdego zadania sprawdź, czy dodane widoki mają trasy i czy adres zmienia się podczas nawigacji. Jeżeli istniejący kod łamie ten standard, nie powielaj starego rozwiązania — dostosuj go do React Routera.
## Behavioral Guidelines To Reduce Common LLM Coding Mistakes
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
+4
View File
@@ -38,6 +38,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
@@ -3,9 +3,12 @@ package pl.polskalokalnie;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling;
// Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego
// uzytkownika Spring Security (i mylacy log "Using generated security password").
// @EnableScheduling wlacza workera wysylajacego kampanie z outboxu.
@EnableScheduling
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
public class PolskaLokalnieApplication {
@@ -27,6 +27,7 @@ import pl.polskalokalnie.listing.ListingStatus;
import pl.polskalokalnie.message.MessageResponse;
import pl.polskalokalnie.message.MessageService;
import pl.polskalokalnie.message.SendMessageRequest;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.report.ListingReportResponse;
import pl.polskalokalnie.report.ListingReportService;
import pl.polskalokalnie.report.ResolveListingReportRequest;
@@ -52,13 +53,15 @@ public class AdminController {
private final TextModerationService textModerationService;
private final AdminStatsService adminStatsService;
private final PasswordEncoder passwordEncoder;
private final NotificationService notificationService;
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
ListingService listingService, ListingReportService listingReportService,
MessageService messageService,
TextModerationService textModerationService,
AdminStatsService adminStatsService,
PasswordEncoder passwordEncoder) {
PasswordEncoder passwordEncoder,
NotificationService notificationService) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
this.listingService = listingService;
@@ -67,6 +70,7 @@ public class AdminController {
this.textModerationService = textModerationService;
this.adminStatsService = adminStatsService;
this.passwordEncoder = passwordEncoder;
this.notificationService = notificationService;
}
// --- Uzytkownicy ---
@@ -93,7 +97,12 @@ public class AdminController {
public UserResponse verify(@PathVariable Long id) {
AppUser user = requireUser(id);
user.setVerified(true);
return UserResponse.from(userRepository.save(user));
AppUser saved = userRepository.save(user);
notificationService.create(saved.getEmail(), "system", "check",
"Konto zweryfikowane",
"Twoje konto zostało zweryfikowane przez administratora. Masz dostęp do wszystkich funkcji serwisu.",
"accountSecurity", "account-verified");
return UserResponse.from(saved);
}
@PostMapping("/users/{id}/grant-admin")
@@ -104,7 +113,12 @@ public class AdminController {
}
user.setRole(Role.ADMIN);
user.setVerified(true);
return UserResponse.from(userRepository.save(user));
AppUser saved = userRepository.save(user);
notificationService.create(saved.getEmail(), "system", "shield",
"Nadano uprawnienia administratora",
"Twoje konto otrzymało rolę administratora.",
"account", null);
return UserResponse.from(saved);
}
@PostMapping("/users/{id}/revoke-admin")
@@ -137,6 +151,10 @@ public class AdminController {
String temporaryPassword = generateTemporaryPassword();
user.setPasswordHash(passwordEncoder.encode(temporaryPassword));
userRepository.save(user);
notificationService.create(user.getEmail(), "system", "lock",
"Hasło zostało zresetowane",
"Administrator zresetował hasło do Twojego konta. Zaloguj się nowym hasłem i ustaw własne w ustawieniach bezpieczeństwa.",
"accountSecurity", null);
return new ResetPasswordResponse(temporaryPassword);
}
@@ -7,6 +7,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.lead.LeadSyncService;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.auth.dto.AuthResponse;
import pl.polskalokalnie.auth.dto.LoginRequest;
@@ -31,19 +32,22 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final TextModerationService textModerationService;
private final LeadSyncService leadSyncService;
public AuthService(
UserRepository userRepository,
BlockedEmailRepository blockedEmailRepository,
PasswordEncoder passwordEncoder,
JwtService jwtService,
TextModerationService textModerationService
TextModerationService textModerationService,
LeadSyncService leadSyncService
) {
this.userRepository = userRepository;
this.blockedEmailRepository = blockedEmailRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.textModerationService = textModerationService;
this.leadSyncService = leadSyncService;
}
public AuthResponse register(RegisterRequest request) {
@@ -69,7 +73,9 @@ public class AuthService {
// Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora.
user.setVerified(false);
return buildAuthResponse(userRepository.save(user));
AppUser saved = userRepository.save(user);
leadSyncService.syncUser(saved);
return buildAuthResponse(saved);
}
public AuthResponse login(LoginRequest request) {
@@ -121,6 +127,7 @@ public class AuthService {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
}
leadSyncService.syncUser(user);
return buildAuthResponse(user);
}
@@ -151,7 +158,9 @@ public class AuthService {
user.setBirthDate(null);
}
return UserResponse.from(userRepository.save(user));
AppUser saved = userRepository.save(user);
leadSyncService.syncUser(saved);
return UserResponse.from(saved);
}
private AuthResponse buildAuthResponse(AppUser user) {
@@ -30,9 +30,8 @@ public class JwtAuthFilter extends OncePerRequestFilter {
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
String token = resolveToken(request);
if (token != null) {
try {
Claims claims = jwtService.parse(token);
String email = claims.getSubject();
@@ -48,4 +47,17 @@ 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);
}
String param = request.getParameter("access_token");
if (param != null && !param.isBlank()) {
return param;
}
return null;
}
}
@@ -37,6 +37,7 @@ public class SecurityConfig {
.requestMatchers("/error").permitAll()
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll()
.requestMatchers("/api/i18n/translate").permitAll()
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
.requestMatchers("/api/auth/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
@@ -0,0 +1,171 @@
package pl.polskalokalnie.campaign;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.time.LocalDate;
import org.hibernate.annotations.ColumnDefault;
/**
* Kampania marketingowa wysylana jednym kanalem (e-mail lub SMS) do wskazanej grupy docelowej.
* Outbox (pozycje {@link CampaignRecipient}) budowany jest przy starcie wysylki; worker
* przetwarza go z dziennym limitem. Tresc zawiera znaczniki personalizacji i stopke z rezygnacja.
*/
@Entity
@Table(name = "campaigns")
public class Campaign {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 160)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private Channel channel;
// Temat dotyczy tylko kanalu e-mail.
@Column(length = 200)
private String subject;
@Column(nullable = false, length = 4000)
private String body;
@Column(nullable = false)
private Long targetGroupId;
private Instant scheduledAt;
@Column(nullable = false)
@ColumnDefault("200")
private Integer dailyLimit = 200;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
@ColumnDefault("'DRAFT'")
private CampaignStatus status = CampaignStatus.DRAFT;
// Ile wyslano dzisiaj (do egzekwowania dziennego limitu) i data resetu licznika.
@Column(nullable = false)
@ColumnDefault("0")
private Integer sentToday = 0;
private LocalDate sentTodayDate;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@Column(length = 180)
private String createdBy;
@jakarta.persistence.PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Channel getChannel() {
return channel;
}
public void setChannel(Channel channel) {
this.channel = channel;
}
public String getSubject() {
return subject;
}
public void setSubject(String subject) {
this.subject = subject;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public Long getTargetGroupId() {
return targetGroupId;
}
public void setTargetGroupId(Long targetGroupId) {
this.targetGroupId = targetGroupId;
}
public Instant getScheduledAt() {
return scheduledAt;
}
public void setScheduledAt(Instant scheduledAt) {
this.scheduledAt = scheduledAt;
}
public Integer getDailyLimit() {
return dailyLimit;
}
public void setDailyLimit(Integer dailyLimit) {
this.dailyLimit = dailyLimit;
}
public CampaignStatus getStatus() {
return status;
}
public void setStatus(CampaignStatus status) {
this.status = status;
}
public Integer getSentToday() {
return sentToday;
}
public void setSentToday(Integer sentToday) {
this.sentToday = sentToday;
}
public LocalDate getSentTodayDate() {
return sentTodayDate;
}
public void setSentTodayDate(LocalDate sentTodayDate) {
this.sentTodayDate = sentTodayDate;
}
public Instant getCreatedAt() {
return createdAt;
}
public String getCreatedBy() {
return createdBy;
}
public void setCreatedBy(String createdBy) {
this.createdBy = createdBy;
}
}
@@ -0,0 +1,72 @@
package pl.polskalokalnie.campaign;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* Kampanie marketingowe w panelu administratora.
*/
@RestController
@RequestMapping("/api/admin/campaigns")
public class CampaignController {
private final CampaignService campaignService;
public CampaignController(CampaignService campaignService) {
this.campaignService = campaignService;
}
@GetMapping
public List<CampaignResponse> list() {
return campaignService.list();
}
@GetMapping("/{id}")
public CampaignResponse get(@PathVariable Long id) {
return campaignService.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CampaignResponse create(@Valid @RequestBody CampaignRequest request, Authentication authentication) {
return campaignService.create(request, authentication.getName());
}
@PutMapping("/{id}")
public CampaignResponse update(@PathVariable Long id, @Valid @RequestBody CampaignRequest request) {
return campaignService.update(id, request);
}
@PostMapping("/{id}/schedule")
public CampaignResponse schedule(@PathVariable Long id) {
return campaignService.schedule(id);
}
@PostMapping("/{id}/pause")
public CampaignResponse pause(@PathVariable Long id) {
return campaignService.pause(id);
}
@GetMapping("/{id}/stats")
public CampaignStatsResponse stats(@PathVariable Long id) {
return campaignService.stats(id);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
campaignService.delete(id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,147 @@
package pl.polskalokalnie.campaign;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
/**
* Pojedyncza pozycja outboxu kampanii - jeden odbiorca. Adres (e-mail/telefon) jest zapisany jako
* migawka w chwili budowy outboxu, zeby pozniejsze zmiany leada nie zmienialy juz zakolejkowanej
* wysylki.
*/
@Entity
@Table(name = "campaign_recipients", indexes = {
@Index(name = "idx_campaign_recipients_campaign", columnList = "campaignId"),
@Index(name = "idx_campaign_recipients_status", columnList = "status")
})
public class CampaignRecipient {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long campaignId;
private Long leadId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
private Channel channel;
@Column(nullable = false, length = 200)
private String address;
@Column(length = 160)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private RecipientStatus status = RecipientStatus.QUEUED;
@Column(length = 500)
private String error;
@Column(length = 200)
private String providerMessageId;
private Instant sentAt;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public Long getCampaignId() {
return campaignId;
}
public void setCampaignId(Long campaignId) {
this.campaignId = campaignId;
}
public Long getLeadId() {
return leadId;
}
public void setLeadId(Long leadId) {
this.leadId = leadId;
}
public Channel getChannel() {
return channel;
}
public void setChannel(Channel channel) {
this.channel = channel;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public RecipientStatus getStatus() {
return status;
}
public void setStatus(RecipientStatus status) {
this.status = status;
}
public String getError() {
return error;
}
public void setError(String error) {
this.error = error;
}
public String getProviderMessageId() {
return providerMessageId;
}
public void setProviderMessageId(String providerMessageId) {
this.providerMessageId = providerMessageId;
}
public Instant getSentAt() {
return sentAt;
}
public void setSentAt(Instant sentAt) {
this.sentAt = sentAt;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,16 @@
package pl.polskalokalnie.campaign;
import java.util.List;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CampaignRecipientRepository extends JpaRepository<CampaignRecipient, Long> {
List<CampaignRecipient> findByCampaignId(Long campaignId);
long countByCampaignIdAndStatus(Long campaignId, RecipientStatus status);
List<CampaignRecipient> findByCampaignIdAndStatusOrderByIdAsc(Long campaignId, RecipientStatus status, Limit limit);
boolean existsByCampaignId(Long campaignId);
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.campaign;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CampaignRepository extends JpaRepository<Campaign, Long> {
List<Campaign> findByStatus(CampaignStatus status);
}
@@ -0,0 +1,17 @@
package pl.polskalokalnie.campaign;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
public record CampaignRequest(
@NotBlank String name,
@NotNull Channel channel,
String subject,
@NotBlank String body,
@NotNull Long targetGroupId,
Instant scheduledAt,
@NotNull @Min(1) Integer dailyLimit
) {
}
@@ -0,0 +1,39 @@
package pl.polskalokalnie.campaign;
import java.time.Instant;
public record CampaignResponse(
Long id,
String name,
Channel channel,
String subject,
String body,
Long targetGroupId,
Instant scheduledAt,
Integer dailyLimit,
CampaignStatus status,
Integer sentToday,
Instant createdAt,
String createdBy,
long totalRecipients,
long sentCount
) {
public static CampaignResponse from(Campaign campaign, long totalRecipients, long sentCount) {
return new CampaignResponse(
campaign.getId(),
campaign.getName(),
campaign.getChannel(),
campaign.getSubject(),
campaign.getBody(),
campaign.getTargetGroupId(),
campaign.getScheduledAt(),
campaign.getDailyLimit(),
campaign.getStatus(),
campaign.getSentToday(),
campaign.getCreatedAt(),
campaign.getCreatedBy(),
totalRecipients,
sentCount
);
}
}
@@ -0,0 +1,213 @@
package pl.polskalokalnie.campaign;
import java.time.Instant;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
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.lead.Lead;
import pl.polskalokalnie.lead.LeadQueryService;
import pl.polskalokalnie.lead.TargetGroup;
import pl.polskalokalnie.lead.TargetGroupRepository;
/**
* Logika kampanii marketingowych: CRUD, budowa outboxu z grupy docelowej oraz sterowanie
* wysylka (harmonogram/pauza). Outbox respektuje zgody marketingowe i trwale rezygnacje -
* odbiorcy bez zgody lub po opt-oucie nie trafiaja do kolejki. Sama wysylka odbywa sie w workerze.
*/
@Service
public class CampaignService {
private final CampaignRepository campaignRepository;
private final CampaignRecipientRepository recipientRepository;
private final TargetGroupRepository targetGroupRepository;
private final LeadQueryService leadQueryService;
public CampaignService(CampaignRepository campaignRepository,
CampaignRecipientRepository recipientRepository,
TargetGroupRepository targetGroupRepository,
LeadQueryService leadQueryService) {
this.campaignRepository = campaignRepository;
this.recipientRepository = recipientRepository;
this.targetGroupRepository = targetGroupRepository;
this.leadQueryService = leadQueryService;
}
public List<CampaignResponse> list() {
return campaignRepository.findAll().stream()
.sorted(Comparator.comparing(Campaign::getCreatedAt).reversed())
.map(this::toResponse)
.toList();
}
public CampaignResponse get(Long id) {
return toResponse(requireCampaign(id));
}
@Transactional
public CampaignResponse create(CampaignRequest request, String createdBy) {
requireGroup(request.targetGroupId());
if (request.channel() == Channel.EMAIL && (request.subject() == null || request.subject().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Kampania e-mail wymaga tematu");
}
Campaign campaign = new Campaign();
apply(campaign, request);
campaign.setStatus(CampaignStatus.DRAFT);
campaign.setCreatedBy(createdBy);
return toResponse(campaignRepository.save(campaign));
}
@Transactional
public CampaignResponse update(Long id, CampaignRequest request) {
Campaign campaign = requireCampaign(id);
if (campaign.getStatus() != CampaignStatus.DRAFT) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Edytować można tylko kampanię w wersji roboczej");
}
requireGroup(request.targetGroupId());
if (request.channel() == Channel.EMAIL && (request.subject() == null || request.subject().isBlank())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Kampania e-mail wymaga tematu");
}
apply(campaign, request);
return toResponse(campaignRepository.save(campaign));
}
@Transactional
public void delete(Long id) {
Campaign campaign = requireCampaign(id);
recipientRepository.deleteAll(recipientRepository.findByCampaignId(campaign.getId()));
campaignRepository.delete(campaign);
}
/**
* 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.
*/
@Transactional
public CampaignResponse schedule(Long id) {
Campaign campaign = requireCampaign(id);
if (campaign.getStatus() == CampaignStatus.COMPLETED || campaign.getStatus() == CampaignStatus.CANCELLED) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Kampania jest już zakończona");
}
if (!recipientRepository.existsByCampaignId(campaign.getId())) {
int built = buildOutbox(campaign);
if (built == 0) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"Brak odbiorców spełniających kryteria (uwzględniono zgody i rezygnacje)");
}
}
boolean future = campaign.getScheduledAt() != null && campaign.getScheduledAt().isAfter(Instant.now());
campaign.setStatus(future ? CampaignStatus.SCHEDULED : CampaignStatus.SENDING);
return toResponse(campaignRepository.save(campaign));
}
@Transactional
public CampaignResponse pause(Long id) {
Campaign campaign = requireCampaign(id);
if (campaign.getStatus() != CampaignStatus.SENDING && campaign.getStatus() != CampaignStatus.SCHEDULED) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Wstrzymać można tylko kampanię w toku lub zaplanowaną");
}
campaign.setStatus(CampaignStatus.PAUSED);
return toResponse(campaignRepository.save(campaign));
}
public CampaignStatsResponse stats(Long id) {
Campaign campaign = requireCampaign(id);
Long cid = campaign.getId();
return new CampaignStatsResponse(
recipientRepository.findByCampaignId(cid).size(),
recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.QUEUED),
recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.SENT),
recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.DELIVERED),
recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.FAILED),
recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.BOUNCED),
recipientRepository.countByCampaignIdAndStatus(cid, RecipientStatus.OPTED_OUT)
);
}
// Buduje pozycje outboxu dla leadow z grupy, z poszanowaniem zgod, rezygnacji i obecnosci adresu.
private int buildOutbox(Campaign campaign) {
TargetGroup group = requireGroup(campaign.getTargetGroupId());
List<Lead> members = leadQueryService.resolve(group);
Set<String> seenAddresses = new LinkedHashSet<>();
int built = 0;
for (Lead lead : members) {
String address = eligibleAddress(lead, campaign.getChannel());
if (address == null) {
continue;
}
if (!seenAddresses.add(address.toLowerCase(Locale.ROOT))) {
continue;
}
CampaignRecipient recipient = new CampaignRecipient();
recipient.setCampaignId(campaign.getId());
recipient.setLeadId(lead.getId());
recipient.setChannel(campaign.getChannel());
recipient.setAddress(address);
recipient.setName(lead.getName());
recipient.setStatus(RecipientStatus.QUEUED);
recipientRepository.save(recipient);
built++;
}
return built;
}
// Zwraca adres kwalifikujacy leada do wysylki na danym kanale albo null, gdy nie kwalifikuje sie
// (brak adresu, brak zgody marketingowej lub trwala rezygnacja).
private static String eligibleAddress(Lead lead, Channel channel) {
if (channel == Channel.EMAIL) {
if (hasText(lead.getEmail()) && lead.isEmailConsent() && !lead.isEmailOptOut()) {
return lead.getEmail().trim();
}
return null;
}
if (hasText(lead.getPhone()) && lead.isSmsConsent() && !lead.isSmsOptOut()) {
return lead.getPhone().trim();
}
return null;
}
private void apply(Campaign campaign, CampaignRequest request) {
campaign.setName(request.name().trim());
campaign.setChannel(request.channel());
campaign.setSubject(trimToNull(request.subject()));
campaign.setBody(request.body());
campaign.setTargetGroupId(request.targetGroupId());
campaign.setScheduledAt(request.scheduledAt());
campaign.setDailyLimit(request.dailyLimit());
}
private Campaign requireCampaign(Long id) {
return campaignRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Kampania nie istnieje"));
}
private TargetGroup requireGroup(Long id) {
return targetGroupRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Wskazana grupa docelowa nie istnieje"));
}
private CampaignResponse toResponse(Campaign campaign) {
long total = recipientRepository.findByCampaignId(campaign.getId()).size();
long sent = recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.SENT)
+ recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.DELIVERED);
return CampaignResponse.from(campaign, total, sent);
}
private static boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,15 @@
package pl.polskalokalnie.campaign;
/**
* Statystyki kampanii - agregat licznosci pozycji outboxu wg statusu.
*/
public record CampaignStatsResponse(
long total,
long queued,
long sent,
long delivered,
long failed,
long bounced,
long optedOut
) {
}
@@ -0,0 +1,14 @@
package pl.polskalokalnie.campaign;
/**
* Cykl zycia kampanii. SENDING oznacza, ze outbox jest przetwarzany przez workera z limitem
* dziennym; PAUSED wstrzymuje wysylke bez utraty postepu; COMPLETED - caly outbox rozliczony.
*/
public enum CampaignStatus {
DRAFT,
SCHEDULED,
SENDING,
PAUSED,
COMPLETED,
CANCELLED
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.campaign;
public enum Channel {
EMAIL,
SMS
}
@@ -0,0 +1,15 @@
package pl.polskalokalnie.campaign;
/**
* Status pojedynczej pozycji outboxu kampanii. QUEUED czeka na wysylke; SENT wyslane do
* dostawcy; DELIVERED potwierdzone; FAILED/BOUNCED bledy; OPTED_OUT - odbiorca zrezygnowal
* przed wysylka i zostal pominiety.
*/
public enum RecipientStatus {
QUEUED,
SENT,
DELIVERED,
FAILED,
BOUNCED,
OPTED_OUT
}
@@ -0,0 +1,39 @@
package pl.polskalokalnie.config;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.ApplicationArguments;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.lead.LeadSyncService;
import pl.polskalokalnie.user.UserRepository;
/**
* Jednorazowy backfill: zakłada leady REGISTERED_USER dla istniejących kont, które ich jeszcze
* nie mają. Kolejne rejestracje/aktualizacje profilu utrzymują synchronizację na bieżąco.
* Uruchamia się po SchemaFixer, żeby tabele leadów były już gotowe.
*/
@Component
@Order(100)
public class LeadBackfillRunner implements ApplicationRunner {
private final UserRepository userRepository;
private final LeadRepository leadRepository;
private final LeadSyncService leadSyncService;
public LeadBackfillRunner(UserRepository userRepository, LeadRepository leadRepository,
LeadSyncService leadSyncService) {
this.userRepository = userRepository;
this.leadRepository = leadRepository;
this.leadSyncService = leadSyncService;
}
@Override
public void run(ApplicationArguments args) {
userRepository.findAll().forEach(user -> {
if (leadRepository.findByUserId(user.getId()).isEmpty()) {
leadSyncService.syncUser(user);
}
});
}
}
@@ -48,6 +48,16 @@ public class SchemaFixer {
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT"
);
// Hibernate (ddl-auto=update) nie aktualizuje CHECK constraintu enuma po dodaniu nowej wartosci.
// Odtwarzamy go tak, aby dopuszczal status PAUSED (wstrzymane ogloszenie uzytkownika).
jdbcTemplate.execute(
"ALTER TABLE property_listings DROP CONSTRAINT IF EXISTS property_listings_status_check"
);
jdbcTemplate.execute(
"ALTER TABLE property_listings ADD CONSTRAINT property_listings_status_check "
+ "CHECK (status IN ('PENDING','APPROVED','REJECTED','PAUSED'))"
);
String dataUrlType = jdbcTemplate.query(
"SELECT data_type FROM information_schema.columns WHERE table_name = 'listing_report_attachments' AND column_name = 'data_url'",
rs -> rs.next() ? rs.getString(1) : null
@@ -0,0 +1,13 @@
package pl.polskalokalnie.lead;
import java.util.List;
/**
* Podsumowanie importu: ile dodano, ile pominieto (duplikaty/braki) oraz komunikaty diagnostyczne.
*/
public record ImportResultResponse(
int imported,
int skipped,
List<String> messages
) {
}
@@ -0,0 +1,211 @@
package pl.polskalokalnie.lead;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.ColumnDefault;
/**
* Kontakt marketingowy (lead) - jedno zrodlo prawdy dla odbiorcow kampanii. Zasilany z trzech
* zrodel: zarejestrowani uzytkownicy (synchronizacja), reczne dodanie/import oraz formularze.
* Zgody ({@code emailConsent}/{@code smsConsent}) i trwale rezygnacje ({@code emailOptOut}/
* {@code smsOptOut}) decyduja o kwalifikacji leada do wysylki na danym kanale.
*/
@Entity
@Table(name = "leads", indexes = {
@Index(name = "idx_leads_email", columnList = "email"),
@Index(name = "idx_leads_phone", columnList = "phone"),
@Index(name = "idx_leads_user_id", columnList = "userId")
})
public class Lead {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 160)
private String name;
@Column(length = 180)
private String email;
@Column(length = 30)
private String phone;
@Column(length = 120)
private String city;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private LeadSource source = LeadSource.MANUAL;
// Powiazanie z kontem uzytkownika, gdy source = REGISTERED_USER.
private Long userId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
@ColumnDefault("'NEW'")
private LeadStatus status = LeadStatus.NEW;
// EAGER: tagi sa male i zawsze potrzebne w filtrach segmentow oraz odpowiedziach DTO
// (mapowanie odbywa sie poza sesja, bo open-in-view=false).
@ElementCollection(fetch = jakarta.persistence.FetchType.EAGER)
@CollectionTable(name = "lead_tags", joinColumns = @JoinColumn(name = "lead_id"))
@Column(name = "tag", length = 60)
private List<String> tags = new ArrayList<>();
@Column(nullable = false)
@ColumnDefault("true")
private boolean emailConsent = true;
@Column(nullable = false)
@ColumnDefault("true")
private boolean smsConsent = true;
// Trwala rezygnacja - raz ustawiona nie jest cofana przez kampanie ani synchronizacje.
@Column(nullable = false)
@ColumnDefault("false")
private boolean emailOptOut = false;
@Column(nullable = false)
@ColumnDefault("false")
private boolean smsOptOut = false;
@Column(length = 2000)
private String notes;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public LeadSource getSource() {
return source;
}
public void setSource(LeadSource source) {
this.source = source;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public LeadStatus getStatus() {
return status;
}
public void setStatus(LeadStatus status) {
this.status = status;
}
public List<String> getTags() {
return tags;
}
public void setTags(List<String> tags) {
this.tags = tags;
}
public boolean isEmailConsent() {
return emailConsent;
}
public void setEmailConsent(boolean emailConsent) {
this.emailConsent = emailConsent;
}
public boolean isSmsConsent() {
return smsConsent;
}
public void setSmsConsent(boolean smsConsent) {
this.smsConsent = smsConsent;
}
public boolean isEmailOptOut() {
return emailOptOut;
}
public void setEmailOptOut(boolean emailOptOut) {
this.emailOptOut = emailOptOut;
}
public boolean isSmsOptOut() {
return smsOptOut;
}
public void setSmsOptOut(boolean smsOptOut) {
this.smsOptOut = smsOptOut;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,61 @@
package pl.polskalokalnie.lead;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* Zarzadzanie baza leadow z panelu administratora. Ochrona przez regule /api/admin/** w
* SecurityConfig (rola ADMIN).
*/
@RestController
@RequestMapping("/api/admin/leads")
public class LeadController {
private final LeadService leadService;
public LeadController(LeadService leadService) {
this.leadService = leadService;
}
@GetMapping
public List<LeadResponse> list(
@RequestParam(required = false) LeadSource source,
@RequestParam(required = false) String search
) {
return leadService.list(source, search);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public LeadResponse create(@Valid @RequestBody LeadCreateRequest request) {
return leadService.create(request);
}
@PostMapping("/import")
public ImportResultResponse importCsv(@Valid @RequestBody LeadImportRequest request) {
return leadService.importCsv(request);
}
@PatchMapping("/{id}")
public LeadResponse update(@PathVariable Long id, @Valid @RequestBody LeadUpdateRequest request) {
return leadService.update(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
leadService.delete(id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,19 @@
package pl.polskalokalnie.lead;
import java.util.List;
/**
* Reczne dodanie leada. Wymagany jest przynajmniej e-mail lub telefon - walidacja w serwisie,
* bo zalezy od obu pol naraz.
*/
public record LeadCreateRequest(
String name,
String email,
String phone,
String city,
List<String> tags,
Boolean emailConsent,
Boolean smsConsent,
String notes
) {
}
@@ -0,0 +1,14 @@
package pl.polskalokalnie.lead;
import jakarta.validation.constraints.NotBlank;
/**
* Import listy kontaktow. Klient przesyla surowa tresc CSV (lub liste wierszy oddzielonych
* srednikami/przecinkami). Parsowanie i deduplikacja odbywa sie w serwisie.
* Oczekiwane kolumny: name;email;phone;city (naglowek opcjonalny).
*/
public record LeadImportRequest(
@NotBlank String csv,
String defaultTag
) {
}
@@ -0,0 +1,77 @@
package pl.polskalokalnie.lead;
import java.util.List;
import org.springframework.stereotype.Service;
/**
* Wylicza czlonkow grupy docelowej (dynamicznego segmentu) na podstawie zapisanych kryteriow.
* Puste kryterium = brak ograniczenia. Nie wyklucza tu rezygnacji (opt-out) ani braku adresu -
* to robi budowa kampanii, bo zalezy od kanalu (e-mail vs SMS).
*/
@Service
public class LeadQueryService {
private final LeadRepository leadRepository;
public LeadQueryService(LeadRepository leadRepository) {
this.leadRepository = leadRepository;
}
public List<Lead> resolve(TargetGroup group) {
return leadRepository.findAll().stream()
.filter(lead -> matches(lead, group))
.toList();
}
public long count(TargetGroup group) {
return leadRepository.findAll().stream()
.filter(lead -> matches(lead, group))
.count();
}
private boolean matches(Lead lead, TargetGroup group) {
if (group.getSources() != null && !group.getSources().isEmpty()
&& !group.getSources().contains(lead.getSource())) {
return false;
}
if (hasText(group.getCity())
&& (lead.getCity() == null || !lead.getCity().trim().equalsIgnoreCase(group.getCity().trim()))) {
return false;
}
if (hasText(group.getTag()) && !containsTagIgnoreCase(lead.getTags(), group.getTag().trim())) {
return false;
}
if (group.getStatus() != null && lead.getStatus() != group.getStatus()) {
return false;
}
if (group.isRequireEmail() && !hasText(lead.getEmail())) {
return false;
}
if (group.isRequirePhone() && !hasText(lead.getPhone())) {
return false;
}
if (group.isRequireEmailConsent() && !lead.isEmailConsent()) {
return false;
}
if (group.isRequireSmsConsent() && !lead.isSmsConsent()) {
return false;
}
return true;
}
private static boolean containsTagIgnoreCase(List<String> tags, String tag) {
if (tags == null) {
return false;
}
for (String candidate : tags) {
if (candidate != null && candidate.trim().equalsIgnoreCase(tag)) {
return true;
}
}
return false;
}
private static boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
}
@@ -0,0 +1,15 @@
package pl.polskalokalnie.lead;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LeadRepository extends JpaRepository<Lead, Long> {
Optional<Lead> findByUserId(Long userId);
Optional<Lead> findFirstByEmailIgnoreCase(String email);
Optional<Lead> findFirstByPhone(String phone);
boolean existsByEmailIgnoreCase(String email);
}
@@ -0,0 +1,42 @@
package pl.polskalokalnie.lead;
import java.time.Instant;
import java.util.List;
public record LeadResponse(
Long id,
String name,
String email,
String phone,
String city,
LeadSource source,
Long userId,
LeadStatus status,
List<String> tags,
boolean emailConsent,
boolean smsConsent,
boolean emailOptOut,
boolean smsOptOut,
String notes,
Instant createdAt
) {
public static LeadResponse from(Lead lead) {
return new LeadResponse(
lead.getId(),
lead.getName(),
lead.getEmail(),
lead.getPhone(),
lead.getCity(),
lead.getSource(),
lead.getUserId(),
lead.getStatus(),
List.copyOf(lead.getTags()),
lead.isEmailConsent(),
lead.isSmsConsent(),
lead.isEmailOptOut(),
lead.isSmsOptOut(),
lead.getNotes(),
lead.getCreatedAt()
);
}
}
@@ -0,0 +1,228 @@
package pl.polskalokalnie.lead;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
/**
* Logika bazy leadow: listowanie z filtrami, reczne dodawanie, import listy, edycja i usuwanie.
* Deduplikacja po e-mailu i telefonie, dzieki czemu import i synchronizacja userow nie tworza
* podwojnych kontaktow.
*/
@Service
public class LeadService {
private final LeadRepository leadRepository;
public LeadService(LeadRepository leadRepository) {
this.leadRepository = leadRepository;
}
public List<LeadResponse> list(LeadSource source, String search) {
String needle = search == null ? "" : search.trim().toLowerCase(Locale.ROOT);
return leadRepository.findAll().stream()
.filter(lead -> source == null || lead.getSource() == source)
.filter(lead -> needle.isEmpty() || matchesSearch(lead, needle))
.sorted(Comparator.comparing(Lead::getCreatedAt).reversed())
.map(LeadResponse::from)
.toList();
}
@Transactional
public LeadResponse create(LeadCreateRequest request) {
String email = normalizeEmail(request.email());
String phone = normalizePhone(request.phone());
if (email == null && phone == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Podaj co najmniej e-mail lub telefon");
}
if (email != null && leadRepository.findFirstByEmailIgnoreCase(email).isPresent()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Lead z tym adresem e-mail już istnieje");
}
if (phone != null && leadRepository.findFirstByPhone(phone).isPresent()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Lead z tym numerem telefonu już istnieje");
}
Lead lead = new Lead();
lead.setSource(LeadSource.MANUAL);
lead.setName(trimToNull(request.name()));
lead.setEmail(email);
lead.setPhone(phone);
lead.setCity(trimToNull(request.city()));
lead.setTags(cleanTags(request.tags()));
lead.setEmailConsent(request.emailConsent() == null || request.emailConsent());
lead.setSmsConsent(request.smsConsent() == null || request.smsConsent());
lead.setNotes(trimToNull(request.notes()));
return LeadResponse.from(leadRepository.save(lead));
}
@Transactional
public LeadResponse update(Long id, LeadUpdateRequest request) {
Lead lead = requireLead(id);
if (request.name() != null) {
lead.setName(trimToNull(request.name()));
}
if (request.email() != null) {
String email = normalizeEmail(request.email());
if (email != null) {
leadRepository.findFirstByEmailIgnoreCase(email)
.filter(other -> !other.getId().equals(lead.getId()))
.ifPresent(other -> {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Inny lead ma już ten e-mail");
});
}
lead.setEmail(email);
}
if (request.phone() != null) {
lead.setPhone(normalizePhone(request.phone()));
}
if (request.city() != null) {
lead.setCity(trimToNull(request.city()));
}
if (request.status() != null) {
lead.setStatus(request.status());
}
if (request.tags() != null) {
lead.setTags(cleanTags(request.tags()));
}
if (request.emailConsent() != null) {
lead.setEmailConsent(request.emailConsent());
}
if (request.smsConsent() != null) {
lead.setSmsConsent(request.smsConsent());
}
// Opt-out to trwala rezygnacja - ustawiamy wylacznie na true, nie cofamy z panelu.
if (Boolean.TRUE.equals(request.emailOptOut())) {
lead.setEmailOptOut(true);
}
if (Boolean.TRUE.equals(request.smsOptOut())) {
lead.setSmsOptOut(true);
}
if (request.notes() != null) {
lead.setNotes(trimToNull(request.notes()));
}
return LeadResponse.from(leadRepository.save(lead));
}
@Transactional
public void delete(Long id) {
Lead lead = requireLead(id);
leadRepository.delete(lead);
}
@Transactional
public ImportResultResponse importCsv(LeadImportRequest request) {
List<String> messages = new ArrayList<>();
int imported = 0;
int skipped = 0;
String defaultTag = trimToNull(request.defaultTag());
String[] lines = request.csv().replace("\r\n", "\n").replace("\r", "\n").split("\n");
int lineNumber = 0;
for (String rawLine : lines) {
lineNumber++;
String line = rawLine.trim();
if (line.isEmpty()) {
continue;
}
String[] parts = line.split("[;,\t]", -1);
String header = parts.length > 0 ? parts[0].trim().toLowerCase(Locale.ROOT) : "";
// Pomijamy wiersz naglowka, jesli wyglada na etykiety kolumn.
if (lineNumber == 1 && (header.equals("name") || header.equals("imie") || header.equals("imię")
|| header.equals("nazwa") || header.contains("email") || header.contains("e-mail"))) {
continue;
}
String name = parts.length > 0 ? trimToNull(parts[0]) : null;
String email = normalizeEmail(parts.length > 1 ? parts[1] : null);
String phone = normalizePhone(parts.length > 2 ? parts[2] : null);
String city = parts.length > 3 ? trimToNull(parts[3]) : null;
if (email == null && phone == null) {
skipped++;
messages.add("Wiersz " + lineNumber + ": pominięto (brak e-maila i telefonu)");
continue;
}
if (email != null && leadRepository.findFirstByEmailIgnoreCase(email).isPresent()) {
skipped++;
messages.add("Wiersz " + lineNumber + ": pominięto (e-mail już istnieje)");
continue;
}
if (phone != null && leadRepository.findFirstByPhone(phone).isPresent()) {
skipped++;
messages.add("Wiersz " + lineNumber + ": pominięto (telefon już istnieje)");
continue;
}
Lead lead = new Lead();
lead.setSource(LeadSource.IMPORT);
lead.setName(name);
lead.setEmail(email);
lead.setPhone(phone);
lead.setCity(city);
if (defaultTag != null) {
lead.setTags(new ArrayList<>(List.of(defaultTag)));
}
leadRepository.save(lead);
imported++;
}
return new ImportResultResponse(imported, skipped, messages);
}
private Lead requireLead(Long id) {
return leadRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Lead nie istnieje"));
}
private static boolean matchesSearch(Lead lead, String needle) {
return contains(lead.getName(), needle)
|| contains(lead.getEmail(), needle)
|| contains(lead.getPhone(), needle)
|| contains(lead.getCity(), needle);
}
private static boolean contains(String value, String needle) {
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
}
private static List<String> cleanTags(List<String> tags) {
List<String> cleaned = new ArrayList<>();
if (tags != null) {
Set<String> seen = new LinkedHashSet<>();
for (String tag : tags) {
String trimmed = trimToNull(tag);
if (trimmed != null && seen.add(trimmed.toLowerCase(Locale.ROOT))) {
cleaned.add(trimmed);
}
}
}
return cleaned;
}
private static String normalizeEmail(String email) {
String trimmed = trimToNull(email);
return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT);
}
private static String normalizePhone(String phone) {
if (phone == null) {
return null;
}
String cleaned = phone.replaceAll("[\\s\\-()]", "").trim();
return cleaned.isEmpty() ? null : cleaned;
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.lead;
/**
* Zrodlo pochodzenia leada. REGISTERED_USER jest synchronizowany z kontem uzytkownika,
* pozostale powstaja recznie, przez import listy lub z formularzy na stronie.
*/
public enum LeadSource {
REGISTERED_USER,
MANUAL,
IMPORT,
FORM
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.lead;
/**
* Status kontaktu w lejku marketingowym. RESPONDED oznacza, ze lead odpowiedzial - takie
* kontakty mozna wykluczyc z kolejnych wysylek.
*/
public enum LeadStatus {
NEW,
CONTACTED,
RESPONDED,
UNSUBSCRIBED
}
@@ -0,0 +1,63 @@
package pl.polskalokalnie.lead;
import java.util.Locale;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.polskalokalnie.user.AppUser;
/**
* Utrzymuje leady typu REGISTERED_USER w zgodzie z kontami uzytkownikow. Wolane przy rejestracji
* i aktualizacji profilu oraz przez backfill przy starcie aplikacji. Zgody marketingowe (mail/SMS)
* uzytkownika przechowujemy na jego leadzie - to jedno zrodlo prawdy dla kwalifikacji do kampanii.
*/
@Service
public class LeadSyncService {
private final LeadRepository leadRepository;
public LeadSyncService(LeadRepository leadRepository) {
this.leadRepository = leadRepository;
}
@Transactional
public Lead syncUser(AppUser user) {
if (user == null || user.getId() == null) {
return null;
}
Lead lead = leadRepository.findByUserId(user.getId())
.orElseGet(() -> leadRepository.findFirstByEmailIgnoreCase(user.getEmail()).orElseGet(Lead::new));
lead.setSource(LeadSource.REGISTERED_USER);
lead.setUserId(user.getId());
lead.setName(user.getFullName());
lead.setEmail(user.getEmail() == null ? null : user.getEmail().toLowerCase(Locale.ROOT));
lead.setPhone(normalizePhone(user.getPhone()));
return leadRepository.save(lead);
}
/**
* Zwraca lead powiazany z uzytkownikiem, tworzac go w razie potrzeby - uzywane przez ekran
* zgod marketingowych uzytkownika.
*/
@Transactional
public Lead getOrCreateForUser(AppUser user) {
Lead lead = leadRepository.findByUserId(user.getId()).orElse(null);
return lead != null ? lead : syncUser(user);
}
@Transactional
public Lead updateConsentForUser(AppUser user, boolean emailConsent, boolean smsConsent) {
Lead lead = getOrCreateForUser(user);
lead.setEmailConsent(emailConsent);
lead.setSmsConsent(smsConsent);
return leadRepository.save(lead);
}
private static String normalizePhone(String phone) {
if (phone == null) {
return null;
}
String cleaned = phone.replaceAll("[\\s\\-()]", "").trim();
return cleaned.isEmpty() ? null : cleaned;
}
}
@@ -0,0 +1,22 @@
package pl.polskalokalnie.lead;
import java.util.List;
/**
* Aktualizacja leada z panelu. Pola null pozostawiaja dotychczasowa wartosc; kolekcja tagow i
* flagi zgod, gdy podane, nadpisuja stan. Opt-out ustawiamy tylko na true (trwala rezygnacja).
*/
public record LeadUpdateRequest(
String name,
String email,
String phone,
String city,
LeadStatus status,
List<String> tags,
Boolean emailConsent,
Boolean smsConsent,
Boolean emailOptOut,
Boolean smsOptOut,
String notes
) {
}
@@ -0,0 +1,74 @@
package pl.polskalokalnie.lead;
import jakarta.validation.constraints.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
/**
* Zgody marketingowe zalogowanego uzytkownika. Zapisywane na leadzie powiazanym z kontem, dzieki
* czemu realnie steruja kwalifikacja uzytkownika jako odbiorcy kampanii. Endpoint wymaga
* uwierzytelnienia (regula anyRequest().authenticated() w SecurityConfig).
*/
@RestController
@RequestMapping("/api/me/marketing")
public class MarketingConsentController {
private final UserRepository userRepository;
private final LeadSyncService leadSyncService;
public MarketingConsentController(UserRepository userRepository, LeadSyncService leadSyncService) {
this.userRepository = userRepository;
this.leadSyncService = leadSyncService;
}
@GetMapping
public MarketingConsentResponse get(Authentication authentication) {
AppUser user = currentUser(authentication);
Lead lead = leadSyncService.getOrCreateForUser(user);
return MarketingConsentResponse.from(lead);
}
@PutMapping
public MarketingConsentResponse update(Authentication authentication, @RequestBody MarketingConsentRequest request) {
AppUser user = currentUser(authentication);
Lead lead = leadSyncService.updateConsentForUser(user,
Boolean.TRUE.equals(request.emailConsent()),
Boolean.TRUE.equals(request.smsConsent()));
return MarketingConsentResponse.from(lead);
}
private AppUser currentUser(Authentication authentication) {
return userRepository.findByEmailIgnoreCase(authentication.getName())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
}
public record MarketingConsentRequest(
@NotNull Boolean emailConsent,
@NotNull Boolean smsConsent
) {
}
public record MarketingConsentResponse(
boolean emailConsent,
boolean smsConsent,
boolean emailOptOut,
boolean smsOptOut
) {
static MarketingConsentResponse from(Lead lead) {
return new MarketingConsentResponse(
lead.isEmailConsent(),
lead.isSmsConsent(),
lead.isEmailOptOut(),
lead.isSmsOptOut()
);
}
}
}
@@ -0,0 +1,165 @@
package pl.polskalokalnie.lead;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
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.JoinColumn;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Grupa docelowa jako dynamiczny segment - przechowuje zestaw kryteriow filtra, a nie zamrozona
* liste leadow. Czlonkowie wyliczaja sie na biezaco w {@link LeadQueryService} przy podgladzie
* i przy starcie kampanii. Puste kryterium oznacza brak ograniczenia dla danego pola.
*/
@Entity
@Table(name = "target_groups")
public class TargetGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 140)
private String name;
@Column(length = 400)
private String description;
// EAGER: zbior zrodel jest maly i zawsze uzywany przy wyliczaniu segmentu oraz w DTO
// (mapowanie poza sesja - open-in-view=false).
@ElementCollection(fetch = jakarta.persistence.FetchType.EAGER)
@CollectionTable(name = "target_group_sources", joinColumns = @JoinColumn(name = "group_id"))
@Column(name = "source", length = 20)
@Enumerated(EnumType.STRING)
private Set<LeadSource> sources = new LinkedHashSet<>();
@Column(length = 120)
private String city;
@Column(length = 60)
private String tag;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private LeadStatus status;
@Column(nullable = false)
private boolean requireEmail = false;
@Column(nullable = false)
private boolean requirePhone = false;
@Column(nullable = false)
private boolean requireEmailConsent = false;
@Column(nullable = false)
private boolean requireSmsConsent = false;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Set<LeadSource> getSources() {
return sources;
}
public void setSources(Set<LeadSource> sources) {
this.sources = sources;
}
public String getCity() {
return city;
}
public void setCity(String city) {
this.city = city;
}
public String getTag() {
return tag;
}
public void setTag(String tag) {
this.tag = tag;
}
public LeadStatus getStatus() {
return status;
}
public void setStatus(LeadStatus status) {
this.status = status;
}
public boolean isRequireEmail() {
return requireEmail;
}
public void setRequireEmail(boolean requireEmail) {
this.requireEmail = requireEmail;
}
public boolean isRequirePhone() {
return requirePhone;
}
public void setRequirePhone(boolean requirePhone) {
this.requirePhone = requirePhone;
}
public boolean isRequireEmailConsent() {
return requireEmailConsent;
}
public void setRequireEmailConsent(boolean requireEmailConsent) {
this.requireEmailConsent = requireEmailConsent;
}
public boolean isRequireSmsConsent() {
return requireSmsConsent;
}
public void setRequireSmsConsent(boolean requireSmsConsent) {
this.requireSmsConsent = requireSmsConsent;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,56 @@
package pl.polskalokalnie.lead;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* Grupy docelowe (dynamiczne segmenty) w panelu administratora.
*/
@RestController
@RequestMapping("/api/admin/target-groups")
public class TargetGroupController {
private final TargetGroupService targetGroupService;
public TargetGroupController(TargetGroupService targetGroupService) {
this.targetGroupService = targetGroupService;
}
@GetMapping
public List<TargetGroupResponse> list() {
return targetGroupService.list();
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TargetGroupResponse create(@Valid @RequestBody TargetGroupRequest request) {
return targetGroupService.create(request);
}
@PutMapping("/{id}")
public TargetGroupResponse update(@PathVariable Long id, @Valid @RequestBody TargetGroupRequest request) {
return targetGroupService.update(id, request);
}
@GetMapping("/{id}/preview")
public TargetGroupPreviewResponse preview(@PathVariable Long id) {
return targetGroupService.preview(id);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
targetGroupService.delete(id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,12 @@
package pl.polskalokalnie.lead;
import java.util.List;
/**
* Podglad segmentu: laczna liczba dopasowanych leadow oraz krotka probka do wyswietlenia w panelu.
*/
public record TargetGroupPreviewResponse(
long total,
List<LeadResponse> sample
) {
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.lead;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TargetGroupRepository extends JpaRepository<TargetGroup, Long> {
}
@@ -0,0 +1,18 @@
package pl.polskalokalnie.lead;
import jakarta.validation.constraints.NotBlank;
import java.util.Set;
public record TargetGroupRequest(
@NotBlank String name,
String description,
Set<LeadSource> sources,
String city,
String tag,
LeadStatus status,
boolean requireEmail,
boolean requirePhone,
boolean requireEmailConsent,
boolean requireSmsConsent
) {
}
@@ -0,0 +1,39 @@
package pl.polskalokalnie.lead;
import java.time.Instant;
import java.util.LinkedHashSet;
import java.util.Set;
public record TargetGroupResponse(
Long id,
String name,
String description,
Set<LeadSource> sources,
String city,
String tag,
LeadStatus status,
boolean requireEmail,
boolean requirePhone,
boolean requireEmailConsent,
boolean requireSmsConsent,
long memberCount,
Instant createdAt
) {
public static TargetGroupResponse from(TargetGroup group, long memberCount) {
return new TargetGroupResponse(
group.getId(),
group.getName(),
group.getDescription(),
new LinkedHashSet<>(group.getSources()),
group.getCity(),
group.getTag(),
group.getStatus(),
group.isRequireEmail(),
group.isRequirePhone(),
group.isRequireEmailConsent(),
group.isRequireSmsConsent(),
memberCount,
group.getCreatedAt()
);
}
}
@@ -0,0 +1,88 @@
package pl.polskalokalnie.lead;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
public class TargetGroupService {
private static final int PREVIEW_SAMPLE_SIZE = 20;
private final TargetGroupRepository targetGroupRepository;
private final LeadQueryService leadQueryService;
public TargetGroupService(TargetGroupRepository targetGroupRepository, LeadQueryService leadQueryService) {
this.targetGroupRepository = targetGroupRepository;
this.leadQueryService = leadQueryService;
}
public List<TargetGroupResponse> list() {
return targetGroupRepository.findAll().stream()
.sorted(Comparator.comparing(TargetGroup::getCreatedAt).reversed())
.map(group -> TargetGroupResponse.from(group, leadQueryService.count(group)))
.toList();
}
@Transactional
public TargetGroupResponse create(TargetGroupRequest request) {
TargetGroup group = new TargetGroup();
apply(group, request);
TargetGroup saved = targetGroupRepository.save(group);
return TargetGroupResponse.from(saved, leadQueryService.count(saved));
}
@Transactional
public TargetGroupResponse update(Long id, TargetGroupRequest request) {
TargetGroup group = requireGroup(id);
apply(group, request);
TargetGroup saved = targetGroupRepository.save(group);
return TargetGroupResponse.from(saved, leadQueryService.count(saved));
}
@Transactional
public void delete(Long id) {
TargetGroup group = requireGroup(id);
targetGroupRepository.delete(group);
}
public TargetGroupPreviewResponse preview(Long id) {
TargetGroup group = requireGroup(id);
List<Lead> members = leadQueryService.resolve(group);
List<LeadResponse> sample = members.stream()
.limit(PREVIEW_SAMPLE_SIZE)
.map(LeadResponse::from)
.toList();
return new TargetGroupPreviewResponse(members.size(), sample);
}
TargetGroup requireGroup(Long id) {
return targetGroupRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Grupa docelowa nie istnieje"));
}
private void apply(TargetGroup group, TargetGroupRequest request) {
group.setName(request.name().trim());
group.setDescription(trimToNull(request.description()));
group.setSources(request.sources() == null ? new LinkedHashSet<>() : new LinkedHashSet<>(request.sources()));
group.setCity(trimToNull(request.city()));
group.setTag(trimToNull(request.tag()));
group.setStatus(request.status());
group.setRequireEmail(request.requireEmail());
group.setRequirePhone(request.requirePhone());
group.setRequireEmailConsent(request.requireEmailConsent());
group.setRequireSmsConsent(request.requireSmsConsent());
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -5,7 +5,9 @@ import java.net.URI;
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.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -55,4 +57,20 @@ public class ListingController {
.created(URI.create("/api/listings/" + response.id()))
.body(response);
}
// Wstrzymanie/wznowienie wlasnego ogloszenia (status=PAUSED lub APPROVED).
@PatchMapping("/{id}/status")
public ListingResponse updateStatus(
@PathVariable Long id,
@RequestParam ListingStatus status,
Authentication authentication
) {
return listingService.setOwnStatus(id, authentication.getName(), status);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
listingService.deleteOwn(id, authentication.getName());
return ResponseEntity.noContent().build();
}
}
@@ -26,6 +26,7 @@ public record ListingResponse(
String coverPhoto,
String ownerEmail,
ListingStatus status,
Long viewsCount,
Instant createdAt
) {
public static ListingResponse from(PropertyListing listing) {
@@ -48,6 +49,7 @@ public record ListingResponse(
listing.getCoverPhoto(),
listing.getOwnerEmail(),
listing.getStatus(),
listing.getViewsCount(),
listing.getCreatedAt()
);
}
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
@Service
public class ListingService {
@@ -18,10 +19,13 @@ public class ListingService {
private final ListingRepository listingRepository;
private final TextModerationService textModerationService;
private final NotificationService notificationService;
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService) {
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService,
NotificationService notificationService) {
this.listingRepository = listingRepository;
this.textModerationService = textModerationService;
this.notificationService = notificationService;
}
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
@@ -56,6 +60,39 @@ public class ListingService {
.toList();
}
// --- Operacje wlasciciela na wlasnym ogloszeniu ---
// Wstrzymanie/wznowienie widocznosci wlasnego ogloszenia (tylko miedzy APPROVED i PAUSED).
@Transactional
public ListingResponse setOwnStatus(Long id, String ownerEmail, ListingStatus status) {
if (status != ListingStatus.APPROVED && status != ListingStatus.PAUSED) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieobslugiwana zmiana statusu");
}
PropertyListing listing = listingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
requireOwner(listing, ownerEmail);
if (listing.getStatus() != ListingStatus.APPROVED && listing.getStatus() != ListingStatus.PAUSED) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Tylko opublikowane ogloszenie mozna wstrzymac lub wznowic");
}
listing.setStatus(status);
return ListingResponse.from(listingRepository.save(listing));
}
@Transactional
public void deleteOwn(Long id, String ownerEmail) {
PropertyListing listing = listingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
requireOwner(listing, ownerEmail);
listingRepository.delete(listing);
}
private void requireOwner(PropertyListing listing, String ownerEmail) {
if (ownerEmail == null || listing.getOwnerEmail() == null
|| !ownerEmail.equalsIgnoreCase(listing.getOwnerEmail())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak uprawnien do tego ogloszenia");
}
}
@Transactional
public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) {
textModerationService.validateOrThrow(
@@ -123,7 +160,32 @@ public class ListingService {
PropertyListing listing = listingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
listing.setStatus(status);
return ListingResponse.from(listingRepository.save(listing));
PropertyListing saved = listingRepository.save(listing);
notifyOwnerStatusChange(saved, status);
return ListingResponse.from(saved);
}
// Powiadomienie dla wlasciciela po decyzji moderacji (approve/reject).
private void notifyOwnerStatusChange(PropertyListing listing, ListingStatus status) {
if (listing.getOwnerEmail() == null) {
return;
}
String title;
String body;
String icon;
if (status == ListingStatus.APPROVED) {
title = "Ogłoszenie opublikowane";
body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało zatwierdzone i jest już widoczne w serwisie.";
icon = "check";
} else if (status == ListingStatus.REJECTED) {
title = "Ogłoszenie odrzucone";
body = "Twoje ogłoszenie „" + listing.getTitle() + "” zostało odrzucone przez moderację.";
icon = "warning";
} else {
return;
}
notificationService.create(listing.getOwnerEmail(), "listing", icon, title, body,
"listing:" + listing.getId(), "status-" + listing.getId() + "-" + status);
}
public void delete(Long id) {
@@ -3,5 +3,6 @@ package pl.polskalokalnie.listing;
public enum ListingStatus {
PENDING,
APPROVED,
REJECTED
REJECTED,
PAUSED
}
@@ -0,0 +1,80 @@
package pl.polskalokalnie.mailconfig;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import pl.polskalokalnie.send.EmailSender;
import pl.polskalokalnie.send.SendResult;
import pl.polskalokalnie.send.SmsSender;
/**
* Konfiguracja poczty SMTP i bramki SMS w panelu administratora oraz kontrolowana wysylka testowa
* do pojedynczego odbiorcy. Sekrety nigdy nie sa zwracane - w odpowiedzi jedynie flaga "ustawione".
*/
@RestController
@RequestMapping("/api/admin/mail-config")
public class MailConfigController {
private final MailConfigService mailConfigService;
private final EmailSender emailSender;
private final SmsSender smsSender;
public MailConfigController(MailConfigService mailConfigService, EmailSender emailSender, SmsSender smsSender) {
this.mailConfigService = mailConfigService;
this.emailSender = emailSender;
this.smsSender = smsSender;
}
@GetMapping("/smtp")
public SmtpConfigResponse getSmtp() {
return SmtpConfigResponse.from(mailConfigService.getSmtp());
}
@PutMapping("/smtp")
public SmtpConfigResponse updateSmtp(@RequestBody SmtpConfigRequest request) {
return SmtpConfigResponse.from(mailConfigService.updateSmtp(request));
}
@GetMapping("/sms")
public SmsConfigResponse getSms() {
return SmsConfigResponse.from(mailConfigService.getSms());
}
@PutMapping("/sms")
public SmsConfigResponse updateSms(@RequestBody SmsConfigRequest request) {
return SmsConfigResponse.from(mailConfigService.updateSms(request));
}
@PostMapping("/smtp/test")
public TestResultResponse testSmtp(@Valid @RequestBody TestSendRequest request) {
SendResult result = emailSender.send(
mailConfigService.getSmtp(),
request.recipient().trim(),
"Test konfiguracji SMTP Polska Lokalnie",
"<p>To jest wiadomość testowa potwierdzająca poprawną konfigurację poczty SMTP.</p>");
return TestResultResponse.from(result);
}
@PostMapping("/sms/test")
public TestResultResponse testSms(@Valid @RequestBody TestSendRequest request) {
SendResult result = smsSender.send(
mailConfigService.getSms(),
request.recipient().trim(),
"Test konfiguracji bramki SMS Polska Lokalnie");
return TestResultResponse.from(result);
}
public record TestSendRequest(@NotBlank String recipient) {
}
public record TestResultResponse(boolean ok, String providerMessageId, String error) {
static TestResultResponse from(SendResult result) {
return new TestResultResponse(result.ok(), result.providerMessageId(), result.error());
}
}
}
@@ -0,0 +1,91 @@
package pl.polskalokalnie.mailconfig;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Dostep do jednorekordowej konfiguracji SMTP i bramki SMS. Sekrety (haslo, klucz API) sa
* nadpisywane tylko przy jawnej zmianie; puste pole w zadaniu zachowuje dotychczasowa wartosc.
* Metody get* zwracaja encje z sekretami wylacznie na uzytek wewnetrzny (sendery) - nie sa
* serializowane do klienta.
*/
@Service
public class MailConfigService {
private static final long SINGLETON_ID = 1L;
private final SmtpConfigRepository smtpConfigRepository;
private final SmsGatewayConfigRepository smsGatewayConfigRepository;
public MailConfigService(SmtpConfigRepository smtpConfigRepository,
SmsGatewayConfigRepository smsGatewayConfigRepository) {
this.smtpConfigRepository = smtpConfigRepository;
this.smsGatewayConfigRepository = smsGatewayConfigRepository;
}
@Transactional
public SmtpConfig getSmtp() {
return smtpConfigRepository.findById(SINGLETON_ID).orElseGet(() -> {
SmtpConfig config = new SmtpConfig();
config.setId(SINGLETON_ID);
return smtpConfigRepository.save(config);
});
}
@Transactional
public SmsGatewayConfig getSms() {
return smsGatewayConfigRepository.findById(SINGLETON_ID).orElseGet(() -> {
SmsGatewayConfig config = new SmsGatewayConfig();
config.setId(SINGLETON_ID);
return smsGatewayConfigRepository.save(config);
});
}
@Transactional
public SmtpConfig updateSmtp(SmtpConfigRequest request) {
SmtpConfig config = getSmtp();
config.setHost(trimToNull(request.host()));
config.setPort(request.port());
if (request.sslEnabled() != null) {
config.setSslEnabled(request.sslEnabled());
}
config.setUsername(trimToNull(request.username()));
config.setFromName(trimToNull(request.fromName()));
config.setContactFormRecipient(trimToNull(request.contactFormRecipient()));
if (request.enabled() != null) {
config.setEnabled(request.enabled());
}
// Haslo zmieniamy tylko gdy podane (niepuste) - inaczej zachowujemy dotychczasowe.
String password = request.password();
if (password != null && !password.isBlank()) {
config.setPassword(password);
}
return smtpConfigRepository.save(config);
}
@Transactional
public SmsGatewayConfig updateSms(SmsConfigRequest request) {
SmsGatewayConfig config = getSms();
config.setEndpointUrl(trimToNull(request.endpointUrl()));
config.setCreator(trimToNull(request.creator()));
if (request.timeoutSeconds() != null) {
config.setTimeoutSeconds(request.timeoutSeconds());
}
if (request.enabled() != null) {
config.setEnabled(request.enabled());
}
String apiKey = request.apiKey();
if (apiKey != null && !apiKey.isBlank()) {
config.setApiKey(apiKey);
}
return smsGatewayConfigRepository.save(config);
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,13 @@
package pl.polskalokalnie.mailconfig;
/**
* Zapis konfiguracji bramki SMS. Pole {@code apiKey} nadpisuje klucz tylko gdy niepuste.
*/
public record SmsConfigRequest(
String endpointUrl,
String apiKey,
String creator,
Integer timeoutSeconds,
Boolean enabled
) {
}
@@ -0,0 +1,23 @@
package pl.polskalokalnie.mailconfig;
/**
* Odpowiedz konfiguracji bramki SMS - bez klucza API. {@code apiKeySet} sygnalizuje jedynie, czy
* klucz jest zapisany (badge "ustawione").
*/
public record SmsConfigResponse(
String endpointUrl,
String creator,
Integer timeoutSeconds,
boolean enabled,
boolean apiKeySet
) {
public static SmsConfigResponse from(SmsGatewayConfig config) {
return new SmsConfigResponse(
config.getEndpointUrl(),
config.getCreator(),
config.getTimeoutSeconds(),
config.isEnabled(),
config.getApiKey() != null && !config.getApiKey().isBlank()
);
}
}
@@ -0,0 +1,81 @@
package pl.polskalokalnie.mailconfig;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* Konfiguracja bramki SMS (SoftSPM) - pojedynczy rekord (id = 1). Klucz API trzymany w bazie,
* nigdy zwracany w odpowiedziach API (maskowany) ani logowany.
*/
@Entity
@Table(name = "sms_gateway_config")
public class SmsGatewayConfig {
@Id
private Long id = 1L;
@Column(length = 400)
private String endpointUrl;
@Column(length = 400)
private String apiKey;
// Pole "creator" bramki - znacznik zrodla wiadomosci, max 50 znakow.
@Column(length = 50)
private String creator;
private Integer timeoutSeconds = 10;
@Column(nullable = false)
private boolean enabled = false;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getEndpointUrl() {
return endpointUrl;
}
public void setEndpointUrl(String endpointUrl) {
this.endpointUrl = endpointUrl;
}
public String getApiKey() {
return apiKey;
}
public void setApiKey(String apiKey) {
this.apiKey = apiKey;
}
public String getCreator() {
return creator;
}
public void setCreator(String creator) {
this.creator = creator;
}
public Integer getTimeoutSeconds() {
return timeoutSeconds;
}
public void setTimeoutSeconds(Integer timeoutSeconds) {
this.timeoutSeconds = timeoutSeconds;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.mailconfig;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SmsGatewayConfigRepository extends JpaRepository<SmsGatewayConfig, Long> {
}
@@ -0,0 +1,114 @@
package pl.polskalokalnie.mailconfig;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* Konfiguracja konta e-mail (SMTP) - pojedynczy rekord (id = 1). Haslo trzymane w bazie, nigdy
* zwracane w odpowiedziach API (maskowane) ani logowane.
*/
@Entity
@Table(name = "smtp_config")
public class SmtpConfig {
@Id
private Long id = 1L;
@Column(length = 200)
private String host;
private Integer port;
@Column(nullable = false)
private boolean sslEnabled = true;
// Login SMTP = adres nadawcy.
@Column(length = 200)
private String username;
@Column(length = 160)
private String fromName;
@Column(length = 400)
private String password;
@Column(length = 200)
private String contactFormRecipient;
@Column(nullable = false)
private boolean enabled = false;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getHost() {
return host;
}
public void setHost(String host) {
this.host = host;
}
public Integer getPort() {
return port;
}
public void setPort(Integer port) {
this.port = port;
}
public boolean isSslEnabled() {
return sslEnabled;
}
public void setSslEnabled(boolean sslEnabled) {
this.sslEnabled = sslEnabled;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFromName() {
return fromName;
}
public void setFromName(String fromName) {
this.fromName = fromName;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getContactFormRecipient() {
return contactFormRecipient;
}
public void setContactFormRecipient(String contactFormRecipient) {
this.contactFormRecipient = contactFormRecipient;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
@@ -0,0 +1,6 @@
package pl.polskalokalnie.mailconfig;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SmtpConfigRepository extends JpaRepository<SmtpConfig, Long> {
}
@@ -0,0 +1,17 @@
package pl.polskalokalnie.mailconfig;
/**
* Zapis konfiguracji SMTP. Pole {@code password} nadpisuje haslo tylko gdy niepuste - puste
* pozostawia dotychczasowe (wzorzec "wpisz, aby zmienic").
*/
public record SmtpConfigRequest(
String host,
Integer port,
Boolean sslEnabled,
String username,
String fromName,
String password,
String contactFormRecipient,
Boolean enabled
) {
}
@@ -0,0 +1,29 @@
package pl.polskalokalnie.mailconfig;
/**
* Odpowiedz konfiguracji SMTP - bez hasla. {@code passwordSet} mowi tylko, czy haslo jest
* zapisane (badge "ustawione"), sama wartosc nigdy nie opuszcza serwera.
*/
public record SmtpConfigResponse(
String host,
Integer port,
boolean sslEnabled,
String username,
String fromName,
String contactFormRecipient,
boolean enabled,
boolean passwordSet
) {
public static SmtpConfigResponse from(SmtpConfig config) {
return new SmtpConfigResponse(
config.getHost(),
config.getPort(),
config.isSslEnabled(),
config.getUsername(),
config.getFromName(),
config.getContactFormRecipient(),
config.isEnabled(),
config.getPassword() != null && !config.getPassword().isBlank()
);
}
}
@@ -5,6 +5,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.Role;
import pl.polskalokalnie.user.UserRepository;
@@ -15,15 +16,18 @@ public class MessageService {
private final MessageRepository messageRepository;
private final UserRepository userRepository;
private final TextModerationService textModerationService;
private final NotificationService notificationService;
public MessageService(
MessageRepository messageRepository,
UserRepository userRepository,
TextModerationService textModerationService
TextModerationService textModerationService,
NotificationService notificationService
) {
this.messageRepository = messageRepository;
this.userRepository = userRepository;
this.textModerationService = textModerationService;
this.notificationService = notificationService;
}
public AppUser resolveAdmin() {
@@ -46,9 +50,38 @@ public class MessageService {
message.setRecipientId(recipientId);
message.setContent(content.trim());
Message saved = messageRepository.save(message);
notifyRecipient(senderId, recipientId, saved.getContent());
return MessageResponse.from(saved, senderId);
}
// Powiadomienie dla odbiorcy kazdej wiadomosci: admin->user ("Wiadomosc od obslugi"),
// user->admin traktujemy jako zapytanie/wiadomosc do obslugi.
private void notifyRecipient(Long senderId, Long recipientId, String content) {
AppUser recipient = userRepository.findById(recipientId).orElse(null);
if (recipient == null) {
return;
}
AppUser sender = userRepository.findById(senderId).orElse(null);
String senderName = sender != null && sender.getFullName() != null && !sender.getFullName().isBlank()
? sender.getFullName()
: "użytkownika";
String preview = content.length() > 90 ? content.substring(0, 90) + "" : content;
if (recipient.getRole() == Role.ADMIN) {
notificationService.create(recipient.getEmail(), "messages", "message",
"Nowa wiadomość od użytkownika",
senderName + ": " + preview,
"messages", null);
} else {
notificationService.create(recipient.getEmail(), "messages", "mail",
"Wiadomość od obsługi Mieszko",
preview,
"messages:admin", null);
}
}
public long countUnreadFrom(Long recipientId, Long senderId) {
return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId);
}
@@ -0,0 +1,19 @@
package pl.polskalokalnie.notification;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Zadanie utworzenia powiadomienia dla zalogowanego uzytkownika (zdarzenia liczone po stronie klienta:
* alerty cenowe, spotkania, ulubione, weryfikacja telefonu, dopasowane oferty).
* userEmail NIE jest przyjmowany z ciala - serwer ustawia go z tokenu.
*/
public record CreateNotificationRequest(
@NotBlank @Size(max = 20) String category,
@Size(max = 40) String icon,
@NotBlank @Size(max = 200) String title,
@Size(max = 600) String body,
@Size(max = 200) String link,
@Size(max = 160) String dedupeKey
) {
}
@@ -0,0 +1,128 @@
package pl.polskalokalnie.notification;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "notifications", indexes = {
@Index(name = "idx_notifications_user", columnList = "userEmail"),
@Index(name = "idx_notifications_user_dedupe", columnList = "userEmail,dedupeKey")
})
public class Notification {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String userEmail;
// Kategoria zgodna z frontendem: listing | messages | system.
@Column(nullable = false, length = 20)
private String category;
@Column(nullable = false, length = 40)
private String icon;
@Column(nullable = false, length = 200)
private String title;
@Column(length = 600)
private String body;
// Deskryptor deep-linku, np. "listing:12", "messages:admin", "priceAlerts", "meetings".
@Column(length = 200)
private String link;
// Klucz idempotencji - blokuje duplikaty tego samego zdarzenia (np. jeden spadek ceny).
@Column(length = 160)
private String dedupeKey;
@Column(nullable = false)
private boolean read = false;
@Column(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 String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getIcon() {
return icon;
}
public void setIcon(String icon) {
this.icon = icon;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
public String getLink() {
return link;
}
public void setLink(String link) {
this.link = link;
}
public String getDedupeKey() {
return dedupeKey;
}
public void setDedupeKey(String dedupeKey) {
this.dedupeKey = dedupeKey;
}
public boolean isRead() {
return read;
}
public void setRead(boolean read) {
this.read = read;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,71 @@
package pl.polskalokalnie.notification;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
@RestController
@RequestMapping("/api/notifications")
public class NotificationController {
private final NotificationService notificationService;
public NotificationController(NotificationService notificationService) {
this.notificationService = notificationService;
}
@GetMapping
public List<NotificationResponse> list(Authentication authentication) {
return notificationService.listOwn(authentication.getName());
}
@GetMapping("/unread-count")
public UnreadCountResponse unreadCount(Authentication authentication) {
return new UnreadCountResponse(notificationService.unreadCount(authentication.getName()));
}
@PostMapping("/{id}/read")
public ResponseEntity<Void> markRead(@PathVariable Long id, Authentication authentication) {
notificationService.markRead(id, authentication.getName());
return ResponseEntity.noContent().build();
}
@PostMapping("/read-all")
public ResponseEntity<Void> markAllRead(Authentication authentication) {
notificationService.markAllRead(authentication.getName());
return ResponseEntity.noContent().build();
}
// Zdarzenia liczone po stronie klienta (alerty cenowe, spotkania, ulubione, weryfikacja telefonu,
// dopasowane oferty). Powiadomienie zawsze trafia do zalogowanego uzytkownika (serwer ustawia odbiorce).
@PostMapping
public NotificationResponse create(@Valid @RequestBody CreateNotificationRequest request, Authentication authentication) {
Notification created = notificationService.create(
authentication.getName(),
request.category(),
request.icon(),
request.title(),
request.body(),
request.link(),
request.dedupeKey()
);
return created == null ? null : NotificationResponse.from(created);
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(Authentication authentication) {
return notificationService.subscribe(authentication.getName());
}
public record UnreadCountResponse(long count) {
}
}
@@ -0,0 +1,20 @@
package pl.polskalokalnie.notification;
import java.util.List;
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 NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(String userEmail);
long countByUserEmailIgnoreCaseAndReadFalse(String userEmail);
boolean existsByUserEmailIgnoreCaseAndDedupeKey(String userEmail, String dedupeKey);
@Modifying
@Query("update Notification n set n.read = true where lower(n.userEmail) = lower(:email) and n.read = false")
int markAllRead(@Param("email") String email);
}
@@ -0,0 +1,27 @@
package pl.polskalokalnie.notification;
import java.time.Instant;
public record NotificationResponse(
Long id,
String category,
String icon,
String title,
String body,
String link,
boolean read,
Instant createdAt
) {
public static NotificationResponse from(Notification n) {
return new NotificationResponse(
n.getId(),
n.getCategory(),
n.getIcon(),
n.getTitle(),
n.getBody(),
n.getLink(),
n.isRead(),
n.getCreatedAt()
);
}
}
@@ -0,0 +1,143 @@
package pl.polskalokalnie.notification;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
* Centralny serwis powiadomien. Zapisuje powiadomienia per uzytkownik i wypycha je w czasie
* rzeczywistym przez SSE. Inne moduly (wiadomosci, ogloszenia, administracja) wolaja {@link #create}.
*/
@Service
public class NotificationService {
private static final long SSE_TIMEOUT_MS = 30L * 60L * 1000L;
private final NotificationRepository repository;
// email uzytkownika -> otwarte polaczenia SSE (moze byc kilka kart/urzadzen).
private final Map<String, CopyOnWriteArrayList<SseEmitter>> emitters = new ConcurrentHashMap<>();
public NotificationService(NotificationRepository repository) {
this.repository = repository;
}
@Transactional
public Notification create(String userEmail, String category, String icon, String title,
String body, String link, String dedupeKey) {
if (userEmail == null || userEmail.isBlank() || title == null || title.isBlank()) {
return null;
}
if (dedupeKey != null && !dedupeKey.isBlank()
&& repository.existsByUserEmailIgnoreCaseAndDedupeKey(userEmail, dedupeKey)) {
return null;
}
Notification notification = new Notification();
notification.setUserEmail(userEmail);
notification.setCategory(normalizeCategory(category));
notification.setIcon(icon == null || icon.isBlank() ? defaultIcon(category) : icon);
notification.setTitle(title.trim());
notification.setBody(body == null ? null : body.trim());
notification.setLink(link == null || link.isBlank() ? null : link.trim());
notification.setDedupeKey(dedupeKey == null || dedupeKey.isBlank() ? null : dedupeKey.trim());
Notification saved = repository.save(notification);
push(userEmail, NotificationResponse.from(saved));
return saved;
}
public List<NotificationResponse> listOwn(String email) {
return repository.findTop100ByUserEmailIgnoreCaseOrderByCreatedAtDesc(email).stream()
.map(NotificationResponse::from)
.toList();
}
public long unreadCount(String email) {
return repository.countByUserEmailIgnoreCaseAndReadFalse(email);
}
@Transactional
public void markRead(Long id, String email) {
Notification notification = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Powiadomienie nie istnieje"));
if (email == null || !email.equalsIgnoreCase(notification.getUserEmail())) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Brak dostepu do tego powiadomienia");
}
if (!notification.isRead()) {
notification.setRead(true);
repository.save(notification);
}
}
@Transactional
public void markAllRead(String email) {
repository.markAllRead(email);
}
// --- SSE ---
public SseEmitter subscribe(String email) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
CopyOnWriteArrayList<SseEmitter> list = emitters.computeIfAbsent(email, key -> new CopyOnWriteArrayList<>());
list.add(emitter);
emitter.onCompletion(() -> remove(email, emitter));
emitter.onTimeout(() -> remove(email, emitter));
emitter.onError(error -> remove(email, emitter));
try {
emitter.send(SseEmitter.event().name("ready").data("ok"));
} catch (IOException ex) {
remove(email, emitter);
}
return emitter;
}
private void push(String email, NotificationResponse payload) {
CopyOnWriteArrayList<SseEmitter> list = emitters.get(email);
if (list == null) {
return;
}
for (SseEmitter emitter : list) {
try {
emitter.send(SseEmitter.event().name("notification").data(payload));
} catch (Exception ex) {
remove(email, emitter);
}
}
}
private void remove(String email, SseEmitter emitter) {
CopyOnWriteArrayList<SseEmitter> list = emitters.get(email);
if (list != null) {
list.remove(emitter);
if (list.isEmpty()) {
emitters.remove(email, list);
}
}
}
private String normalizeCategory(String category) {
if ("listing".equals(category) || "messages".equals(category) || "system".equals(category)) {
return category;
}
return "system";
}
private String defaultIcon(String category) {
if ("messages".equals(category)) {
return "message";
}
if ("listing".equals(category)) {
return "house";
}
return "bell";
}
}
@@ -0,0 +1,140 @@
package pl.polskalokalnie.send;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.domain.Limit;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import pl.polskalokalnie.campaign.Campaign;
import pl.polskalokalnie.campaign.CampaignRecipient;
import pl.polskalokalnie.campaign.CampaignRecipientRepository;
import pl.polskalokalnie.campaign.CampaignRepository;
import pl.polskalokalnie.campaign.CampaignStatus;
import pl.polskalokalnie.campaign.Channel;
import pl.polskalokalnie.campaign.RecipientStatus;
import pl.polskalokalnie.mailconfig.MailConfigService;
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
import pl.polskalokalnie.mailconfig.SmtpConfig;
/**
* Worker wysylajacy kampanie z outboxu. Cyklicznie promuje zaplanowane kampanie, ktorych termin
* nadszedl, a nastepnie wysyla zakolejkowane wiadomosci respektujac dzienny limit. Wysylka
* odbywa sie tylko gdy odpowiednia konfiguracja (SMTP/SMS) jest wlaczona - w przeciwnym razie
* pozycje pozostaja w kolejce (tryb sandbox). Jeden przebieg przetwarza ograniczona partie, by
* nie blokowac watku schedulera.
*/
@Component
public class CampaignDispatcher {
private static final int BATCH_PER_TICK = 25;
private final CampaignRepository campaignRepository;
private final CampaignRecipientRepository recipientRepository;
private final MailConfigService mailConfigService;
private final EmailSender emailSender;
private final SmsSender smsSender;
private final MessageComposer messageComposer;
public CampaignDispatcher(CampaignRepository campaignRepository,
CampaignRecipientRepository recipientRepository,
MailConfigService mailConfigService,
EmailSender emailSender,
SmsSender smsSender,
MessageComposer messageComposer) {
this.campaignRepository = campaignRepository;
this.recipientRepository = recipientRepository;
this.mailConfigService = mailConfigService;
this.emailSender = emailSender;
this.smsSender = smsSender;
this.messageComposer = messageComposer;
}
@Scheduled(fixedDelayString = "${app.campaign.dispatch-interval-ms:15000}", initialDelay = 20000)
public void dispatch() {
promoteScheduled();
for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SENDING)) {
processCampaign(campaign);
}
}
private void promoteScheduled() {
Instant now = Instant.now();
for (Campaign campaign : campaignRepository.findByStatus(CampaignStatus.SCHEDULED)) {
if (campaign.getScheduledAt() == null || !campaign.getScheduledAt().isAfter(now)) {
campaign.setStatus(CampaignStatus.SENDING);
campaignRepository.save(campaign);
}
}
}
private void processCampaign(Campaign campaign) {
// Reset dziennego licznika przy zmianie daty.
LocalDate today = LocalDate.now();
if (!today.equals(campaign.getSentTodayDate())) {
campaign.setSentToday(0);
campaign.setSentTodayDate(today);
campaignRepository.save(campaign);
}
int dailyLimit = campaign.getDailyLimit() != null ? campaign.getDailyLimit() : Integer.MAX_VALUE;
int remainingToday = dailyLimit - (campaign.getSentToday() != null ? campaign.getSentToday() : 0);
if (remainingToday <= 0) {
return;
}
// Konfiguracja wlaczona? Jesli nie - tryb sandbox, nic nie wychodzi (pozycje zostaja w kolejce).
SmtpConfig smtp = campaign.getChannel() == Channel.EMAIL ? mailConfigService.getSmtp() : null;
SmsGatewayConfig sms = campaign.getChannel() == Channel.SMS ? mailConfigService.getSms() : null;
if (campaign.getChannel() == Channel.EMAIL && (smtp == null || !smtp.isEnabled())) {
return;
}
if (campaign.getChannel() == Channel.SMS && (sms == null || !sms.isEnabled())) {
return;
}
int batch = Math.min(BATCH_PER_TICK, remainingToday);
List<CampaignRecipient> queued = recipientRepository
.findByCampaignIdAndStatusOrderByIdAsc(campaign.getId(), RecipientStatus.QUEUED, Limit.of(batch));
int sentNow = 0;
for (CampaignRecipient recipient : queued) {
SendResult result = campaign.getChannel() == Channel.EMAIL
? sendEmail(campaign, recipient, smtp)
: sendSms(campaign, recipient, sms);
recipient.setSentAt(Instant.now());
if (result.ok()) {
recipient.setStatus(RecipientStatus.SENT);
recipient.setProviderMessageId(result.providerMessageId());
recipient.setError(null);
sentNow++;
} else {
recipient.setStatus(RecipientStatus.FAILED);
recipient.setError(result.error());
}
recipientRepository.save(recipient);
}
if (sentNow > 0) {
campaign.setSentToday((campaign.getSentToday() != null ? campaign.getSentToday() : 0) + sentNow);
campaignRepository.save(campaign);
}
// Zakonczenie kampanii, gdy nie ma juz nic w kolejce.
if (recipientRepository.countByCampaignIdAndStatus(campaign.getId(), RecipientStatus.QUEUED) == 0) {
campaign.setStatus(CampaignStatus.COMPLETED);
campaignRepository.save(campaign);
}
}
private SendResult sendEmail(Campaign campaign, CampaignRecipient recipient, SmtpConfig smtp) {
String html = messageComposer.composeEmail(campaign.getBody(), recipient.getName(), recipient.getLeadId());
return emailSender.send(smtp, recipient.getAddress(), campaign.getSubject(), html);
}
private SendResult sendSms(Campaign campaign, CampaignRecipient recipient, SmsGatewayConfig sms) {
String text = messageComposer.composeSms(campaign.getBody(), recipient.getName(), recipient.getLeadId());
return smsSender.send(sms, recipient.getAddress(), text);
}
}
@@ -0,0 +1,83 @@
package pl.polskalokalnie.send;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
import java.io.UnsupportedEncodingException;
import java.nio.charset.StandardCharsets;
import java.util.Properties;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import pl.polskalokalnie.mailconfig.SmtpConfig;
/**
* Wysylka pojedynczego e-maila przez konto SMTP z konfiguracji. JavaMailSender budowany jest per
* wywolanie, bo konfiguracja moze zmienic sie w czasie dzialania aplikacji (niski wolumen).
* Dla portu 465 wlacza SSL, w przeciwnym razie STARTTLS.
*/
@Component
public class EmailSender {
public SendResult send(SmtpConfig config, String to, String subject, String htmlBody) {
if (config == null || config.getHost() == null || config.getHost().isBlank()
|| config.getUsername() == null || config.getUsername().isBlank()) {
return SendResult.failure("Konfiguracja SMTP jest niekompletna");
}
try {
JavaMailSenderImpl mailSender = buildMailSender(config);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name());
helper.setFrom(fromAddress(config));
helper.setTo(to);
helper.setSubject(subject == null ? "" : subject);
helper.setText(htmlBody, true);
mailSender.send(message);
String messageId = message.getMessageID();
return SendResult.ok(messageId);
} catch (Exception ex) {
return SendResult.failure(shorten(ex.getMessage()));
}
}
private JavaMailSenderImpl buildMailSender(SmtpConfig config) {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(config.getHost());
int port = config.getPort() != null ? config.getPort() : (config.isSslEnabled() ? 465 : 587);
mailSender.setPort(port);
mailSender.setUsername(config.getUsername());
mailSender.setPassword(config.getPassword());
mailSender.setDefaultEncoding(StandardCharsets.UTF_8.name());
Properties props = mailSender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.connectiontimeout", "10000");
props.put("mail.smtp.timeout", "15000");
props.put("mail.smtp.writetimeout", "15000");
if (config.isSslEnabled()) {
props.put("mail.smtp.ssl.enable", "true");
props.put("mail.smtp.ssl.protocols", "TLSv1.2 TLSv1.3");
} else {
props.put("mail.smtp.starttls.enable", "true");
}
return mailSender;
}
private InternetAddress fromAddress(SmtpConfig config) throws UnsupportedEncodingException {
try {
if (config.getFromName() != null && !config.getFromName().isBlank()) {
return new InternetAddress(config.getUsername(), config.getFromName(), StandardCharsets.UTF_8.name());
}
return new InternetAddress(config.getUsername());
} catch (Exception ex) {
throw new UnsupportedEncodingException("Nieprawidłowy adres nadawcy");
}
}
private static String shorten(String message) {
if (message == null) {
return "Nieznany błąd wysyłki";
}
return message.length() > 480 ? message.substring(0, 480) : message;
}
}
@@ -0,0 +1,69 @@
package pl.polskalokalnie.send;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import pl.polskalokalnie.unsubscribe.UnsubscribeService;
/**
* Sklada finalna tresc wiadomosci: personalizacja znacznikow ({{name}}) oraz obowiazkowa stopka z
* linkiem rezygnacji. Kazda wiadomosc marketingowa musi umozliwiac wypisanie sie - link generuje
* podpisany token per lead i kanal.
*/
@Component
public class MessageComposer {
private final UnsubscribeService unsubscribeService;
private final String baseUrl;
public MessageComposer(UnsubscribeService unsubscribeService,
@Value("${app.public-base-url:http://localhost}") String baseUrl) {
this.unsubscribeService = unsubscribeService;
this.baseUrl = baseUrl == null ? "" : baseUrl.replaceAll("/+$", "");
}
/** 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);
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\">"
+ "Otrzymujesz tę wiadomość, ponieważ wyraziłeś zgodę na kontakt marketingowy. "
+ "<a href=\"" + link + "\" style=\"color:#94a3b8\">Zrezygnuj z otrzymywania wiadomości</a>.</p>";
return "<div style=\"font-family:system-ui,Segoe UI,Arial,sans-serif;color:#0f172a;font-size:15px;"
+ "line-height:1.6\">" + html + footer + "</div>";
}
/** Tresc SMS z personalizacja i krotka informacja o rezygnacji. */
public String composeSms(String rawBody, String recipientName, Long leadId) {
String personalized = personalize(rawBody, recipientName).trim();
String link = unsubscribeLink(leadId, "sms");
return personalized + "\nRezygnacja: " + link;
}
private String unsubscribeLink(Long leadId, String channel) {
return baseUrl + "/api/unsubscribe?token=" + unsubscribeService.generateToken(leadId, channel);
}
private static String personalize(String body, String recipientName) {
String name = recipientName == null || recipientName.isBlank() ? "" : recipientName.trim();
String value = body == null ? "" : body;
// {{name}} -> imie/nazwa; gdy brak, usuwamy podwojne spacje po podstawieniu pustej wartosci.
return value.replace("{{name}}", name).replace("{{ name }}", name);
}
private static boolean looksLikeHtml(String value) {
return value != null && value.contains("<") && value.contains(">");
}
private static String textToHtml(String text) {
return escapeHtml(text).replace("\r\n", "\n").replace("\n", "<br>");
}
private static String escapeHtml(String value) {
return value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;");
}
}
@@ -0,0 +1,18 @@
package pl.polskalokalnie.send;
/**
* Wynik proby wysylki pojedynczej wiadomosci przez sender (e-mail/SMS).
*/
public record SendResult(
boolean ok,
String providerMessageId,
String error
) {
public static SendResult ok(String providerMessageId) {
return new SendResult(true, providerMessageId, null);
}
public static SendResult failure(String error) {
return new SendResult(false, null, error);
}
}
@@ -0,0 +1,73 @@
package pl.polskalokalnie.send;
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 org.springframework.stereotype.Component;
import pl.polskalokalnie.mailconfig.SmsGatewayConfig;
/**
* Wysylka pojedynczego SMS przez bramke SoftSPM ({@code send_sms_api.php}). Zadanie HTTP POST
* (form-urlencoded) z kluczem API, polem creator, numerem i trescia; timeout z konfiguracji.
*
* UWAGA: dokladne nazwy parametrow bramki (apiKey/creator/to/message) nalezy potwierdzic z
* dokumentacja SoftSPM - sa wyodrebnione ponizej, by latwo je dopasowac.
*/
@Component
public class SmsSender {
private static final String PARAM_API_KEY = "apiKey";
private static final String PARAM_CREATOR = "creator";
private static final String PARAM_TO = "to";
private static final String PARAM_MESSAGE = "message";
public SendResult send(SmsGatewayConfig config, String phone, String message) {
if (config == null || config.getEndpointUrl() == null || config.getEndpointUrl().isBlank()
|| config.getApiKey() == null || config.getApiKey().isBlank()) {
return SendResult.failure("Konfiguracja bramki SMS jest niekompletna");
}
try {
int timeout = config.getTimeoutSeconds() != null && config.getTimeoutSeconds() > 0
? config.getTimeoutSeconds() : 10;
String form = PARAM_API_KEY + "=" + enc(config.getApiKey())
+ "&" + PARAM_CREATOR + "=" + enc(config.getCreator())
+ "&" + PARAM_TO + "=" + enc(phone)
+ "&" + PARAM_MESSAGE + "=" + enc(message);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(timeout))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(config.getEndpointUrl()))
.timeout(Duration.ofSeconds(timeout))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
String body = response.body() == null ? "" : response.body().trim();
if (response.statusCode() >= 200 && response.statusCode() < 300) {
return SendResult.ok(shorten(body));
}
return SendResult.failure("Bramka SMS zwróciła status " + response.statusCode()
+ (body.isEmpty() ? "" : ": " + shorten(body)));
} catch (Exception ex) {
return SendResult.failure(shorten(ex.getMessage()));
}
}
private static String enc(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
private static String shorten(String value) {
if (value == null) {
return "";
}
return value.length() > 200 ? value.substring(0, 200) : value;
}
}
@@ -0,0 +1,44 @@
package pl.polskalokalnie.unsubscribe;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Publiczny endpoint rezygnacji - link z tokenem doklejany do kazdej wiadomosci marketingowej.
* Musi byc na liscie permitAll w SecurityConfig. Zwraca prosta strone potwierdzenia po polsku.
*/
@RestController
@RequestMapping("/api/unsubscribe")
public class UnsubscribeController {
private final UnsubscribeService unsubscribeService;
public UnsubscribeController(UnsubscribeService unsubscribeService) {
this.unsubscribeService = unsubscribeService;
}
@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.");
}
private static String page(String title, String message) {
return "<!doctype html><html lang=\"pl\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
+ "<title>" + title + "</title>"
+ "<style>body{font-family:system-ui,Segoe UI,Arial,sans-serif;background:#f5f6fa;margin:0;"
+ "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>";
}
}
@@ -0,0 +1,112 @@
package pl.polskalokalnie.unsubscribe;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Locale;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
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.lead.Lead;
import pl.polskalokalnie.lead.LeadRepository;
import pl.polskalokalnie.lead.LeadStatus;
/**
* Generuje i weryfikuje podpisane (HMAC-SHA256) tokeny rezygnacji doklejane do kazdej wiadomosci
* marketingowej. Rezygnacja jest trwala: ustawia opt-out i zdejmuje zgode na danym kanale, dzieki
* czemu kontakt nie trafi do zadnej kolejnej kampanii.
*/
@Service
public class UnsubscribeService {
private static final String HMAC_ALGO = "HmacSHA256";
private static final Base64.Encoder URL_ENCODER = Base64.getUrlEncoder().withoutPadding();
private static final Base64.Decoder URL_DECODER = Base64.getUrlDecoder();
private final LeadRepository leadRepository;
private final byte[] secret;
public UnsubscribeService(LeadRepository leadRepository, @Value("${app.jwt.secret}") String secret) {
this.leadRepository = leadRepository;
this.secret = secret.getBytes(StandardCharsets.UTF_8);
}
/** Token dla leada na danym kanale ("email" albo "sms"). */
public String generateToken(Long leadId, String channel) {
String payload = leadId + ":" + normalizeChannel(channel);
String encodedPayload = URL_ENCODER.encodeToString(payload.getBytes(StandardCharsets.UTF_8));
return encodedPayload + "." + sign(encodedPayload);
}
@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"));
if ("sms".equals(channel)) {
lead.setSmsOptOut(true);
lead.setSmsConsent(false);
} else {
lead.setEmailOptOut(true);
lead.setEmailConsent(false);
}
lead.setStatus(LeadStatus.UNSUBSCRIBED);
leadRepository.save(lead);
return new Result(channel, maskContact(lead, channel));
}
private String sign(String data) {
try {
Mac mac = Mac.getInstance(HMAC_ALGO);
mac.init(new SecretKeySpec(secret, HMAC_ALGO));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return URL_ENCODER.encodeToString(raw);
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Błąd podpisu tokenu");
}
}
private static Long parseLeadId(String value) {
try {
return Long.parseLong(value);
} catch (NumberFormatException ex) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy link rezygnacji");
}
}
private static String normalizeChannel(String channel) {
return channel != null && channel.trim().toLowerCase(Locale.ROOT).startsWith("sms") ? "sms" : "email";
}
private static String maskContact(Lead lead, String channel) {
String value = "sms".equals(channel) ? lead.getPhone() : lead.getEmail();
if (value == null || value.length() < 4) {
return "";
}
return value.substring(0, 2) + "***" + value.substring(value.length() - 2);
}
public record Result(String channel, String maskedContact) {
}
}
@@ -19,6 +19,8 @@ spring:
format_sql: true
app:
# Publiczny adres bazowy uzywany do budowy linkow (np. rezygnacja z kampanii).
public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost}
jwt:
# W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa.
secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min}
+123 -1
View File
@@ -11,9 +11,11 @@
"@types/pdfmake": "^0.3.3",
"pdfmake": "^0.3.11",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
@@ -108,6 +110,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -536,6 +554,19 @@
"node": ">=0.8"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -977,6 +1008,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/png-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz",
@@ -1039,6 +1117,44 @@
"react": "^18.3.1"
}
},
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/restructure": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
@@ -1097,6 +1213,12 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+5 -2
View File
@@ -6,15 +6,18 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
"preview": "vite preview --host 0.0.0.0",
"test:e2e": "playwright test"
},
"dependencies": {
"@types/pdfmake": "^0.3.3",
"pdfmake": "^0.3.11",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"react-router-dom": "^7.18.1"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig } from '@playwright/test';
// Testy routingu uruchamiamy na systemowym Chrome (bez pobierania przegladarek Playwright).
// Domyslnie startujemy wlasny dev server; ustaw E2E_BASE_URL, zeby przetestowac
// build produkcyjny za nginx, np. E2E_BASE_URL=http://localhost npm run test:e2e
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
reporter: [['list']],
use: {
baseURL,
channel: 'chrome',
viewport: { width: 1680, height: 1050 },
actionTimeout: 15_000,
},
webServer: process.env.E2E_BASE_URL
? undefined
: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: true,
timeout: 120_000,
},
});
+2064 -518
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './auth';
import { ROUTES } from './routes';
/**
* Bramka dostępu dla tras wymagających zalogowania (i opcjonalnie roli ADMIN).
*
* Uwaga na `loading`: po odświeżeniu strony token jest w localStorage, ale profil
* dociąga się asynchronicznie z /auth/me. Bez tego warunku bezpośrednie wejście na
* /konto wyrzucałoby zalogowanego użytkownika na logowanie, zanim profil dotrze.
*
* To zabezpieczenie interfejsu, nie kontrola dostępu do danych — autoryzację
* egzekwuje backend przy każdym endpoincie.
*/
export function ProtectedRoute({ requireAdmin = false }: { requireAdmin?: boolean }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="route-loading" role="status" aria-live="polite">
Wczytywanie
</div>
);
}
if (!user) {
// Zapamiętujemy cel, żeby po zalogowaniu wrócić dokładnie tam, gdzie użytkownik zmierzał.
return <Navigate to={ROUTES.login} state={{ from: `${location.pathname}${location.search}` }} replace />;
}
if (requireAdmin && user.role !== 'ADMIN') {
return <Navigate to={ROUTES.account} replace />;
}
return <Outlet />;
}
+974
View File
@@ -0,0 +1,974 @@
/**
* Widoki panelu administratora dla modułu marketingowego: Leady + grupy docelowe, Kampanie oraz
* konfiguracja poczty SMTP i bramki SMS. Wydzielone z App.tsx (monolit 22k linii), aby go nie
* powiększać. Komponent `Icon` przekazywany jest propem, żeby uniknąć cyklicznego importu.
*/
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../auth';
type IconComponent = (props: { name: string }) => JSX.Element | null;
type LeadSource = 'REGISTERED_USER' | 'MANUAL' | 'IMPORT' | 'FORM';
type LeadStatus = 'NEW' | 'CONTACTED' | 'RESPONDED' | 'UNSUBSCRIBED';
type Lead = {
id: number;
name: string | null;
email: string | null;
phone: string | null;
city: string | null;
source: LeadSource;
userId: number | null;
status: LeadStatus;
tags: string[];
emailConsent: boolean;
smsConsent: boolean;
emailOptOut: boolean;
smsOptOut: boolean;
notes: string | null;
createdAt: string;
};
type TargetGroup = {
id: number;
name: string;
description: string | null;
sources: LeadSource[];
city: string | null;
tag: string | null;
status: LeadStatus | null;
requireEmail: boolean;
requirePhone: boolean;
requireEmailConsent: boolean;
requireSmsConsent: boolean;
memberCount: number;
createdAt: string;
};
type Channel = 'EMAIL' | 'SMS';
type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'PAUSED' | 'COMPLETED' | 'CANCELLED';
type Campaign = {
id: number;
name: string;
channel: Channel;
subject: string | null;
body: string;
targetGroupId: number;
scheduledAt: string | null;
dailyLimit: number;
status: CampaignStatus;
sentToday: number;
createdAt: string;
createdBy: string | null;
totalRecipients: number;
sentCount: number;
};
type CampaignStats = {
total: number;
queued: number;
sent: number;
delivered: number;
failed: number;
bounced: number;
optedOut: number;
};
const SOURCE_LABEL: Record<LeadSource, string> = {
REGISTERED_USER: 'Użytkownik',
MANUAL: 'Ręczny',
IMPORT: 'Import',
FORM: 'Formularz',
};
const STATUS_LABEL: Record<LeadStatus, string> = {
NEW: 'Nowy',
CONTACTED: 'Kontakt',
RESPONDED: 'Odpowiedział',
UNSUBSCRIBED: 'Wypisany',
};
const CAMPAIGN_STATUS_LABEL: Record<CampaignStatus, string> = {
DRAFT: 'Robocza',
SCHEDULED: 'Zaplanowana',
SENDING: 'Wysyłka',
PAUSED: 'Wstrzymana',
COMPLETED: 'Zakończona',
CANCELLED: 'Anulowana',
};
const ALL_SOURCES: LeadSource[] = ['REGISTERED_USER', 'MANUAL', 'IMPORT', 'FORM'];
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Wystąpił błąd. Spróbuj ponownie.';
}
/* ============================ LEADY + GRUPY DOCELOWE ============================ */
export function AdminLeadsView({ Icon }: { Icon: IconComponent }) {
const [section, setSection] = useState<'leads' | 'groups'>('leads');
return (
<div className="mkt-wrap">
<div className="mkt-subnav">
<button type="button" className={section === 'leads' ? 'active' : ''} onClick={() => setSection('leads')}>
<Icon name="user" /> Baza leadów
</button>
<button type="button" className={section === 'groups' ? 'active' : ''} onClick={() => setSection('groups')}>
<Icon name="list" /> Grupy docelowe
</button>
</div>
{section === 'leads' ? <LeadsSection Icon={Icon} /> : <TargetGroupsSection Icon={Icon} />}
</div>
);
}
function LeadsSection({ Icon }: { Icon: IconComponent }) {
const [leads, setLeads] = useState<Lead[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sourceFilter, setSourceFilter] = useState<'' | LeadSource>('');
const [search, setSearch] = useState('');
const [busyId, setBusyId] = useState<number | null>(null);
// Formularz nowego leada.
const [form, setForm] = useState({ name: '', email: '', phone: '', city: '', tags: '' });
const [saving, setSaving] = useState(false);
// Import.
const [csv, setCsv] = useState('');
const [importTag, setImportTag] = useState('');
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (sourceFilter) params.set('source', sourceFilter);
if (search.trim()) params.set('search', search.trim());
const data = await apiFetch<Lead[]>(`/admin/leads${params.toString() ? `?${params.toString()}` : ''}`);
setLeads(data);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, [sourceFilter, search]);
useEffect(() => {
const timer = setTimeout(load, 200);
return () => clearTimeout(timer);
}, [load]);
const addLead = async () => {
if (!form.email.trim() && !form.phone.trim()) {
setError('Podaj co najmniej e-mail lub telefon.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch<Lead>('/admin/leads', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
city: form.city.trim() || null,
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
}),
});
setForm({ name: '', email: '', phone: '', city: '', tags: '' });
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const runImport = async () => {
if (!csv.trim()) return;
setImporting(true);
setImportResult(null);
setError(null);
try {
const res = await apiFetch<{ imported: number; skipped: number; messages: string[] }>(
'/admin/leads/import',
{ method: 'POST', body: JSON.stringify({ csv, defaultTag: importTag.trim() || null }) },
);
setImportResult(`Dodano ${res.imported}, pominięto ${res.skipped}.`);
setCsv('');
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setImporting(false);
}
};
const optOut = async (lead: Lead) => {
setBusyId(lead.id);
try {
await apiFetch(`/admin/leads/${lead.id}`, {
method: 'PATCH',
body: JSON.stringify({ emailOptOut: true, smsOptOut: true }),
});
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
const remove = async (lead: Lead) => {
setBusyId(lead.id);
try {
await apiFetch(`/admin/leads/${lead.id}`, { method: 'DELETE' });
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
return (
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Baza leadów</h2>
<span className="mkt-count">{leads.length} kontaktów</span>
</div>
<div className="mkt-toolbar">
<div className="mkt-search">
<Icon name="search" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Szukaj po nazwie, e-mailu, telefonie, mieście..."
/>
</div>
<select value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value as '' | LeadSource)}>
<option value="">Wszystkie źródła</option>
{ALL_SOURCES.map((s) => (
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
))}
</select>
</div>
<div className="mkt-addrow">
<input placeholder="Nazwa / imię" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input placeholder="E-mail" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input placeholder="Telefon" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<input placeholder="Miasto" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} />
<input placeholder="Tagi (po przecinku)" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
<button type="button" className="admin-btn approve" onClick={addLead} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Dodaj lead'}
</button>
</div>
{error && <p className="mkt-error">{error}</p>}
{loading ? (
<p className="admin-empty">Ładowanie...</p>
) : leads.length === 0 ? (
<p className="admin-empty">Brak leadów. Dodaj kontakt lub zaimportuj listę.</p>
) : (
<div className="mkt-table-scroll">
<table className="mkt-table">
<thead>
<tr>
<th>Nazwa</th><th>Kontakt</th><th>Miasto</th><th>Źródło</th><th>Status</th><th>Zgody</th><th></th>
</tr>
</thead>
<tbody>
{leads.map((lead) => (
<tr key={lead.id}>
<td>{lead.name || '—'}</td>
<td>
<div className="mkt-contact">
{lead.email && <span>{lead.email}</span>}
{lead.phone && <span className="mkt-muted">{lead.phone}</span>}
</div>
</td>
<td>{lead.city || '—'}</td>
<td><span className="mkt-chip">{SOURCE_LABEL[lead.source]}</span></td>
<td>{STATUS_LABEL[lead.status]}</td>
<td>
<div className="mkt-consents">
<span className={`mkt-dot ${lead.emailConsent && !lead.emailOptOut ? 'on' : 'off'}`} title="Zgoda e-mail">@</span>
<span className={`mkt-dot ${lead.smsConsent && !lead.smsOptOut ? 'on' : 'off'}`} title="Zgoda SMS">SMS</span>
</div>
</td>
<td className="mkt-actions">
{!(lead.emailOptOut && lead.smsOptOut) && (
<button type="button" title="Wypisz z kampanii" disabled={busyId === lead.id} onClick={() => optOut(lead)}>
<Icon name="lock" />
</button>
)}
<button type="button" title="Usuń lead" disabled={busyId === lead.id} onClick={() => remove(lead)}>
<Icon name="trash" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mkt-import">
<h3><Icon name="document" /> Import kontaktów (CSV)</h3>
<p className="mkt-muted">Kolumny: <code>nazwa;email;telefon;miasto</code> jeden kontakt w wierszu, nagłówek opcjonalny.</p>
<textarea
rows={4}
value={csv}
onChange={(e) => setCsv(e.target.value)}
placeholder={'Jan Kowalski;jan@example.pl;600100200;Warszawa'}
/>
<div className="mkt-import-actions">
<input placeholder="Tag dla importu (opcjonalnie)" value={importTag} onChange={(e) => setImportTag(e.target.value)} />
<button type="button" className="admin-btn approve" onClick={runImport} disabled={importing || !csv.trim()}>
{importing ? 'Importowanie...' : 'Importuj'}
</button>
</div>
{importResult && <p className="mkt-ok">{importResult}</p>}
</div>
</div>
);
}
function TargetGroupsSection({ Icon }: { Icon: IconComponent }) {
const emptyForm = {
name: '',
description: '',
sources: [] as LeadSource[],
city: '',
tag: '',
requireEmail: false,
requirePhone: false,
requireEmailConsent: false,
requireSmsConsent: false,
};
const [groups, setGroups] = useState<TargetGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setGroups(await apiFetch<TargetGroup[]>('/admin/target-groups'));
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const toggleSource = (s: LeadSource) => {
setForm((f) => ({
...f,
sources: f.sources.includes(s) ? f.sources.filter((x) => x !== s) : [...f.sources, s],
}));
};
const create = async () => {
if (!form.name.trim()) {
setError('Podaj nazwę grupy.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch<TargetGroup>('/admin/target-groups', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim(),
description: form.description.trim() || null,
sources: form.sources,
city: form.city.trim() || null,
tag: form.tag.trim() || null,
requireEmail: form.requireEmail,
requirePhone: form.requirePhone,
requireEmailConsent: form.requireEmailConsent,
requireSmsConsent: form.requireSmsConsent,
}),
});
setForm(emptyForm);
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const remove = async (group: TargetGroup) => {
setBusyId(group.id);
try {
await apiFetch(`/admin/target-groups/${group.id}`, { method: 'DELETE' });
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
return (
<div className="mkt-grid-2">
<div className="admin-card mkt-card">
<div className="admin-card-head"><h2>Nowa grupa docelowa</h2></div>
<p className="mkt-muted">Grupa to dynamiczny segment odbiorcy wyliczają się na bieżąco z kryteriów.</p>
<div className="mkt-form">
<label>Nazwa
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Deweloperzy Warszawa" />
</label>
<label>Opis
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Opcjonalny opis" />
</label>
<fieldset className="mkt-fieldset">
<legend>Źródła (puste = wszystkie)</legend>
<div className="mkt-checks">
{ALL_SOURCES.map((s) => (
<label key={s} className="mkt-check">
<input type="checkbox" checked={form.sources.includes(s)} onChange={() => toggleSource(s)} />
{SOURCE_LABEL[s]}
</label>
))}
</div>
</fieldset>
<div className="mkt-form-row">
<label>Miasto
<input value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} placeholder="dowolne" />
</label>
<label>Tag
<input value={form.tag} onChange={(e) => setForm({ ...form, tag: e.target.value })} placeholder="dowolny" />
</label>
</div>
<div className="mkt-checks mkt-checks-col">
<label className="mkt-check"><input type="checkbox" checked={form.requireEmail} onChange={(e) => setForm({ ...form, requireEmail: e.target.checked })} /> Wymagany e-mail</label>
<label className="mkt-check"><input type="checkbox" checked={form.requirePhone} onChange={(e) => setForm({ ...form, requirePhone: e.target.checked })} /> Wymagany telefon</label>
<label className="mkt-check"><input type="checkbox" checked={form.requireEmailConsent} onChange={(e) => setForm({ ...form, requireEmailConsent: e.target.checked })} /> Tylko ze zgodą e-mail</label>
<label className="mkt-check"><input type="checkbox" checked={form.requireSmsConsent} onChange={(e) => setForm({ ...form, requireSmsConsent: e.target.checked })} /> Tylko ze zgodą SMS</label>
</div>
{error && <p className="mkt-error">{error}</p>}
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Utwórz grupę'}
</button>
</div>
</div>
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Grupy docelowe</h2>
<span className="mkt-count">{groups.length}</span>
</div>
{loading ? (
<p className="admin-empty">Ładowanie...</p>
) : groups.length === 0 ? (
<p className="admin-empty">Brak grup. Utwórz pierwszy segment.</p>
) : (
<ul className="mkt-list">
{groups.map((g) => (
<li key={g.id}>
<div>
<strong>{g.name}</strong>
{g.description && <span className="mkt-muted"> {g.description}</span>}
<div className="mkt-group-meta">
<span className="mkt-badge">{g.memberCount} odbiorców</span>
{g.sources.length > 0 && <span className="mkt-muted">{g.sources.map((s) => SOURCE_LABEL[s]).join(', ')}</span>}
{g.city && <span className="mkt-muted">miasto: {g.city}</span>}
{g.requireEmailConsent && <span className="mkt-muted">zgoda e-mail</span>}
{g.requireSmsConsent && <span className="mkt-muted">zgoda SMS</span>}
</div>
</div>
<button type="button" className="mkt-icon-btn" title="Usuń grupę" disabled={busyId === g.id} onClick={() => remove(g)}>
<Icon name="trash" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
/* ============================ KAMPANIE ============================ */
export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [groups, setGroups] = useState<TargetGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [statsFor, setStatsFor] = useState<{ id: number; stats: CampaignStats } | null>(null);
const empty = { name: '', channel: 'EMAIL' as Channel, subject: '', body: '', targetGroupId: '', dailyLimit: 200, scheduledAt: '' };
const [form, setForm] = useState(empty);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [c, g] = await Promise.all([
apiFetch<Campaign[]>('/admin/campaigns'),
apiFetch<TargetGroup[]>('/admin/target-groups'),
]);
setCampaigns(c);
setGroups(g);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const create = async () => {
if (!form.name.trim() || !form.body.trim() || !form.targetGroupId) {
setError('Uzupełnij nazwę, treść i grupę docelową.');
return;
}
if (form.channel === 'EMAIL' && !form.subject.trim()) {
setError('Kampania e-mail wymaga tematu.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch<Campaign>('/admin/campaigns', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim(),
channel: form.channel,
subject: form.channel === 'EMAIL' ? form.subject.trim() : null,
body: form.body,
targetGroupId: Number(form.targetGroupId),
dailyLimit: Number(form.dailyLimit) || 1,
scheduledAt: form.scheduledAt ? new Date(form.scheduledAt).toISOString() : null,
}),
});
setForm(empty);
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const action = async (id: number, path: string) => {
setBusyId(id);
setError(null);
try {
await apiFetch(`/admin/campaigns/${id}/${path}`, { method: 'POST' });
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
const remove = async (id: number) => {
setBusyId(id);
try {
await apiFetch(`/admin/campaigns/${id}`, { method: 'DELETE' });
if (statsFor?.id === id) setStatsFor(null);
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
const showStats = async (id: number) => {
try {
const stats = await apiFetch<CampaignStats>(`/admin/campaigns/${id}/stats`);
setStatsFor({ id, stats });
} catch (err) {
setError(errorMessage(err));
}
};
return (
<div className="mkt-grid-2">
<div className="admin-card mkt-card">
<div className="admin-card-head"><h2>Nowa kampania</h2></div>
<div className="mkt-form">
<label>Nazwa
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Newsletter lipiec" />
</label>
<div className="mkt-form-row">
<label>Kanał
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as Channel })}>
<option value="EMAIL">E-mail</option>
<option value="SMS">SMS</option>
</select>
</label>
<label>Grupa docelowa
<select value={form.targetGroupId} onChange={(e) => setForm({ ...form, targetGroupId: e.target.value })}>
<option value=""> wybierz </option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name} ({g.memberCount})</option>
))}
</select>
</label>
</div>
{form.channel === 'EMAIL' && (
<label>Temat
<input value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} placeholder="Temat wiadomości" />
</label>
)}
<label>Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
<textarea rows={5} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
</label>
<div className="mkt-form-row">
<label>Dzienny limit
<input type="number" min={1} value={form.dailyLimit} onChange={(e) => setForm({ ...form, dailyLimit: Number(e.target.value) })} />
</label>
<label>Zaplanuj na (opcjonalnie)
<input type="datetime-local" value={form.scheduledAt} onChange={(e) => setForm({ ...form, scheduledAt: e.target.value })} />
</label>
</div>
{error && <p className="mkt-error">{error}</p>}
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Utwórz kampanię (robocza)'}
</button>
</div>
</div>
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Kampanie</h2>
<span className="mkt-count">{campaigns.length}</span>
</div>
{loading ? (
<p className="admin-empty">Ładowanie...</p>
) : campaigns.length === 0 ? (
<p className="admin-empty">Brak kampanii.</p>
) : (
<ul className="mkt-list">
{campaigns.map((c) => (
<li key={c.id} className="mkt-campaign">
<div>
<strong>{c.name}</strong>
<span className={`mkt-status mkt-status-${c.status.toLowerCase()}`}>{CAMPAIGN_STATUS_LABEL[c.status]}</span>
<div className="mkt-group-meta">
<span className="mkt-chip">{c.channel === 'EMAIL' ? 'E-mail' : 'SMS'}</span>
<span className="mkt-muted">{c.sentCount}/{c.totalRecipients} wysłanych</span>
<span className="mkt-muted">limit/dzień: {c.dailyLimit}</span>
</div>
{statsFor?.id === c.id && (
<div className="mkt-stats">
w kolejce: {statsFor.stats.queued} · wysłane: {statsFor.stats.sent} · dostarczone: {statsFor.stats.delivered} · błędy: {statsFor.stats.failed}
</div>
)}
</div>
<div className="mkt-campaign-actions">
{(c.status === 'DRAFT' || c.status === 'PAUSED') && (
<button type="button" className="admin-btn approve" disabled={busyId === c.id} onClick={() => action(c.id, 'schedule')}>
Uruchom
</button>
)}
{(c.status === 'SENDING' || c.status === 'SCHEDULED') && (
<button type="button" className="admin-btn" disabled={busyId === c.id} onClick={() => action(c.id, 'pause')}>
Wstrzymaj
</button>
)}
<button type="button" className="mkt-icon-btn" title="Statystyki" onClick={() => showStats(c.id)}><Icon name="chart" /></button>
<button type="button" className="mkt-icon-btn" title="Usuń" disabled={busyId === c.id} onClick={() => remove(c.id)}><Icon name="trash" /></button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
/* ============================ KONFIGURACJA SMTP + SMS ============================ */
type SmtpConfig = {
host: string | null;
port: number | null;
sslEnabled: boolean;
username: string | null;
fromName: string | null;
contactFormRecipient: string | null;
enabled: boolean;
passwordSet: boolean;
};
type SmsConfig = {
endpointUrl: string | null;
creator: string | null;
timeoutSeconds: number | null;
enabled: boolean;
apiKeySet: boolean;
};
export function AdminMailConfigView({ Icon }: { Icon: IconComponent }) {
return (
<div className="mkt-wrap">
<SmtpCard Icon={Icon} />
<SmsCard Icon={Icon} />
</div>
);
}
function SmtpCard({ Icon }: { Icon: IconComponent }) {
const [config, setConfig] = useState<SmtpConfig | null>(null);
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [note, setNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [testTo, setTestTo] = useState('');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setConfig(await apiFetch<SmtpConfig>('/admin/mail-config/smtp'));
} catch (err) {
setError(errorMessage(err));
}
}, []);
useEffect(() => { load(); }, [load]);
if (!config) return <div className="admin-card mkt-card"><p className="admin-empty">Ładowanie konfiguracji SMTP...</p></div>;
const set = (patch: Partial<SmtpConfig>) => setConfig({ ...config, ...patch });
const save = async () => {
setSaving(true);
setNote(null);
setError(null);
try {
const updated = await apiFetch<SmtpConfig>('/admin/mail-config/smtp', {
method: 'PUT',
body: JSON.stringify({
host: config.host,
port: config.port,
sslEnabled: config.sslEnabled,
username: config.username,
fromName: config.fromName,
contactFormRecipient: config.contactFormRecipient,
enabled: config.enabled,
password: password || null,
}),
});
setConfig(updated);
setPassword('');
setNote('Zapisano ustawienia SMTP.');
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const test = async () => {
if (!testTo.trim()) return;
setTesting(true);
setTestResult(null);
try {
const res = await apiFetch<{ ok: boolean; error: string | null }>('/admin/mail-config/smtp/test', {
method: 'POST',
body: JSON.stringify({ recipient: testTo.trim() }),
});
setTestResult(res.ok ? 'Wiadomość testowa wysłana.' : `Błąd: ${res.error}`);
} catch (err) {
setTestResult(`Błąd: ${errorMessage(err)}`);
} finally {
setTesting(false);
}
};
return (
<div className="admin-card mkt-card mkt-config">
<div className="admin-card-head mkt-config-head">
<span className="mkt-config-icon"><Icon name="mail" /></span>
<h2>Konto e-mail (SMTP)</h2>
</div>
<div className="mkt-config-grid">
<label>Adres odbiorcy wiadomości z formularza kontaktowego
<input value={config.contactFormRecipient ?? ''} onChange={(e) => set({ contactFormRecipient: e.target.value })} placeholder="np. kontakt@twojadomena.pl" />
</label>
<label>Wyświetlana nazwa nadawcy
<input value={config.fromName ?? ''} onChange={(e) => set({ fromName: e.target.value })} placeholder="np. Marketing" />
</label>
<label>Serwer SMTP
<input value={config.host ?? ''} onChange={(e) => set({ host: e.target.value })} placeholder="np. smtp.twojadomena.pl" />
</label>
<label>Hasło do konta e-mail {config.passwordSet && <span className="mkt-badge-set"><Icon name="lock" /> ustawione</span>}
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="wpisz, aby zmienić" />
</label>
<label>Port SMTP (465 = SSL)
<input type="number" value={config.port ?? ''} onChange={(e) => set({ port: e.target.value ? Number(e.target.value) : null })} placeholder="465" />
</label>
<label>Użyj SSL dla połączenia SMTP
<select value={config.sslEnabled ? 'on' : 'off'} onChange={(e) => set({ sslEnabled: e.target.value === 'on' })}>
<option value="on">Włączone</option>
<option value="off">Wyłączone</option>
</select>
</label>
<label>Login SMTP / adres nadawcy
<input value={config.username ?? ''} onChange={(e) => set({ username: e.target.value })} placeholder="np. marketing@twojadomena.pl" />
</label>
<label>Wysyłka kampanii e-mail aktywna
<select value={config.enabled ? 'on' : 'off'} onChange={(e) => set({ enabled: e.target.value === 'on' })}>
<option value="off">Wyłączone (sandbox)</option>
<option value="on">Włączone</option>
</select>
</label>
</div>
{error && <p className="mkt-error">{error}</p>}
{note && <p className="mkt-ok">{note}</p>}
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={save} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Zapisz ustawienia'}
</button>
</div>
<div className="mkt-test">
<h3>Test konfiguracji</h3>
<div className="mkt-test-row">
<input value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="Adres e-mail do testu" />
<button type="button" className="admin-btn" onClick={test} disabled={testing || !testTo.trim()}>
<Icon name="mail" /> {testing ? 'Wysyłanie...' : 'Wyślij (e-mail)'}
</button>
</div>
{testResult && <p className={testResult.startsWith('Błąd') ? 'mkt-error' : 'mkt-ok'}>{testResult}</p>}
</div>
</div>
);
}
function SmsCard({ Icon }: { Icon: IconComponent }) {
const [config, setConfig] = useState<SmsConfig | null>(null);
const [apiKey, setApiKey] = useState('');
const [saving, setSaving] = useState(false);
const [note, setNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [testTo, setTestTo] = useState('');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setConfig(await apiFetch<SmsConfig>('/admin/mail-config/sms'));
} catch (err) {
setError(errorMessage(err));
}
}, []);
useEffect(() => { load(); }, [load]);
if (!config) return <div className="admin-card mkt-card"><p className="admin-empty">Ładowanie konfiguracji SMS...</p></div>;
const set = (patch: Partial<SmsConfig>) => setConfig({ ...config, ...patch });
const save = async () => {
setSaving(true);
setNote(null);
setError(null);
try {
const updated = await apiFetch<SmsConfig>('/admin/mail-config/sms', {
method: 'PUT',
body: JSON.stringify({
endpointUrl: config.endpointUrl,
creator: config.creator,
timeoutSeconds: config.timeoutSeconds,
enabled: config.enabled,
apiKey: apiKey || null,
}),
});
setConfig(updated);
setApiKey('');
setNote('Zapisano ustawienia bramki SMS.');
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const test = async () => {
if (!testTo.trim()) return;
setTesting(true);
setTestResult(null);
try {
const res = await apiFetch<{ ok: boolean; error: string | null }>('/admin/mail-config/sms/test', {
method: 'POST',
body: JSON.stringify({ recipient: testTo.trim() }),
});
setTestResult(res.ok ? 'SMS testowy wysłany.' : `Błąd: ${res.error}`);
} catch (err) {
setTestResult(`Błąd: ${errorMessage(err)}`);
} finally {
setTesting(false);
}
};
return (
<div className="admin-card mkt-card mkt-config">
<div className="admin-card-head mkt-config-head">
<span className="mkt-config-icon"><Icon name="message" /></span>
<h2>Bramka SMS</h2>
</div>
<div className="mkt-config-grid">
<label>Klucz API bramki SMS {config.apiKeySet && <span className="mkt-badge-set"><Icon name="lock" /> ustawione</span>}
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="wpisz, aby zmienić" />
</label>
<label>Znacznik źródła wiadomości (pole creator, max 50 znaków)
<input maxLength={50} value={config.creator ?? ''} onChange={(e) => set({ creator: e.target.value })} placeholder="np. Marketing" />
</label>
<label>Czy wysyłka SMS jest aktywna
<select value={config.enabled ? 'on' : 'off'} onChange={(e) => set({ enabled: e.target.value === 'on' })}>
<option value="off">Wyłączone (sandbox)</option>
<option value="on">Włączone</option>
</select>
</label>
<label>Pełny URL endpointu bramki SMS
<input value={config.endpointUrl ?? ''} onChange={(e) => set({ endpointUrl: e.target.value })} placeholder="https://api.softspm.pl/send_sms_api.php" />
</label>
<label>Timeout żądania HTTP do bramki (s)
<input type="number" value={config.timeoutSeconds ?? ''} onChange={(e) => set({ timeoutSeconds: e.target.value ? Number(e.target.value) : null })} placeholder="10" />
</label>
</div>
{error && <p className="mkt-error">{error}</p>}
{note && <p className="mkt-ok">{note}</p>}
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={save} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Zapisz ustawienia'}
</button>
</div>
<div className="mkt-test">
<h3>Test konfiguracji</h3>
<div className="mkt-test-row">
<input value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="Numer telefonu do testu" />
<button type="button" className="admin-btn" onClick={test} disabled={testing || !testTo.trim()}>
<Icon name="message" /> {testing ? 'Wysyłanie...' : 'Wyślij (SMS)'}
</button>
</div>
{testResult && <p className={testResult.startsWith('Błąd') ? 'mkt-error' : 'mkt-ok'}>{testResult}</p>}
</div>
</div>
);
}
+27 -6
View File
@@ -104,21 +104,42 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
let cancelled = false;
setLoading(true);
apiFetch<AuthUser>('/auth/me')
.then((me) => {
// Sesje konczymy wylacznie wtedy, gdy backend odrzuci token (401/403).
// Chwilowy blad sieci albo 5xx nie moze wylogowywac uzytkownika - taki blad
// ponawiamy raz, a token zostaje, wiec kolejne wejscie na strone go odzyska.
const loadProfile = async (canRetry: boolean): Promise<void> => {
try {
const me = await apiFetch<AuthUser>('/auth/me');
if (!cancelled) {
setUser(me);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
} catch (error) {
if (cancelled) {
return;
}
const status = (error as { status?: number }).status;
if (status === 401 || status === 403) {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
setLoading(false);
return;
}
});
if (canRetry) {
await new Promise((resolve) => { window.setTimeout(resolve, 400); });
if (!cancelled) {
await loadProfile(false);
}
return;
}
setUser(null);
setLoading(false);
}
};
void loadProfile(true);
return () => {
cancelled = true;
};
+9 -3
View File
@@ -1,12 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './auth';
import { NotificationsProvider } from './notifications';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
<BrowserRouter>
<AuthProvider>
<NotificationsProvider>
<App />
</NotificationsProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);
+178
View File
@@ -0,0 +1,178 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { apiFetch, getToken, useAuth } from './auth';
export type NotificationCategoryKey = 'listing' | 'messages' | 'system';
// Powiadomienie w formie z backendu.
export type ServerNotification = {
id: number;
category: NotificationCategoryKey;
icon: string;
title: string;
body: string | null;
link: string | null;
read: boolean;
createdAt: string;
};
// Zdarzenia liczone po stronie klienta (alerty cen, spotkania, ulubione, telefon, dopasowania).
export type NotifyInput = {
category: NotificationCategoryKey;
icon?: string;
title: string;
body?: string;
link?: string;
dedupeKey?: string;
};
// Kształt do renderu (zgodny z istniejącym UI: title/description/timeLabel/dayLabel/category/icon/unread/action).
export type DisplayNotification = ServerNotification & {
description: string;
timeLabel: string;
dayLabel: 'Dzisiaj' | 'Wczoraj';
unread: boolean;
action?: 'arrow';
};
type NotificationsContextValue = {
notifications: ServerNotification[];
unreadCount: number;
markRead: (id: number) => void;
markAllRead: () => void;
notify: (input: NotifyInput) => void;
refresh: () => void;
};
const NotificationsContext = createContext<NotificationsContextValue | null>(null);
function pad(value: number): string {
return value < 10 ? `0${value}` : String(value);
}
// Mapowanie listy z backendu na kształt oczekiwany przez istniejący widok (bez zmiany designu).
export function toDisplayNotifications(list: ServerNotification[]): DisplayNotification[] {
const now = new Date();
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
return list.map((item) => {
const created = new Date(item.createdAt);
const createdMs = created.getTime();
const time = `${pad(created.getHours())}:${pad(created.getMinutes())}`;
const isToday = createdMs >= startOfToday;
const isYesterday = createdMs >= startOfYesterday && createdMs < startOfToday;
let timeLabel: string;
if (isToday) {
timeLabel = time;
} else if (isYesterday) {
timeLabel = `Wczoraj, ${time}`;
} else {
timeLabel = `${pad(created.getDate())}.${pad(created.getMonth() + 1)}, ${time}`;
}
const unread = !item.read;
return {
...item,
description: item.body ?? '',
timeLabel,
// Widok grupuje wyłącznie na "Dzisiaj"/"Wczoraj" — starsze trafiają do "Wczoraj".
dayLabel: isToday ? 'Dzisiaj' : 'Wczoraj',
unread,
action: !unread && item.link ? 'arrow' : undefined,
};
});
}
export function NotificationsProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const [notifications, setNotifications] = useState<ServerNotification[]>([]);
const sentDedupeKeys = useRef<Set<string>>(new Set());
const unreadCount = notifications.reduce((count, item) => (item.read ? count : count + 1), 0);
const refresh = useCallback(() => {
if (!getToken()) {
setNotifications([]);
return;
}
apiFetch<ServerNotification[]>('/notifications')
.then((data) => setNotifications(Array.isArray(data) ? data : []))
.catch(() => { /* cicho - brak sieci/tokenu */ });
}, []);
// Ładowanie listy + realtime (SSE) gdy użytkownik zalogowany. Token w query param (EventSource nie ustawia nagłówków).
useEffect(() => {
const token = getToken();
if (!user || !token) {
setNotifications([]);
sentDedupeKeys.current = new Set();
return;
}
refresh();
const source = new EventSource(`/api/notifications/stream?access_token=${encodeURIComponent(token)}`);
source.addEventListener('notification', (event) => {
try {
const incoming = JSON.parse((event as MessageEvent).data) as ServerNotification;
setNotifications((current) => (current.some((item) => item.id === incoming.id) ? current : [incoming, ...current]));
} catch {
/* ignoruj nieparsowalne zdarzenie */
}
});
// onerror: EventSource sam ponawia połączenie.
// Zapasowy polling na wypadek zerwanego SSE.
const pollId = window.setInterval(refresh, 60000);
return () => {
source.close();
window.clearInterval(pollId);
};
}, [user, refresh]);
const markRead = useCallback((id: number) => {
setNotifications((current) => current.map((item) => (item.id === id ? { ...item, read: true } : item)));
apiFetch(`/notifications/${id}/read`, { method: 'POST' }).catch(() => {});
}, []);
const markAllRead = useCallback(() => {
setNotifications((current) => current.map((item) => ({ ...item, read: true })));
apiFetch('/notifications/read-all', { method: 'POST' }).catch(() => {});
}, []);
const notify = useCallback((input: NotifyInput) => {
if (!getToken() || !input.title) {
return;
}
if (input.dedupeKey) {
if (sentDedupeKeys.current.has(input.dedupeKey)) {
return;
}
sentDedupeKeys.current.add(input.dedupeKey);
}
apiFetch<ServerNotification | null>('/notifications', { method: 'POST', body: JSON.stringify(input) })
.then((created) => {
if (created && created.id) {
setNotifications((current) => (current.some((item) => item.id === created.id) ? current : [created, ...current]));
}
})
.catch(() => {});
}, []);
return (
<NotificationsContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, notify, refresh }}>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications(): NotificationsContextValue {
const context = useContext(NotificationsContext);
if (!context) {
throw new Error('useNotifications musi być użyte wewnątrz NotificationsProvider');
}
return context;
}
+121
View File
@@ -0,0 +1,121 @@
// Centralna mapa adresów aplikacji. Jedyne źródło prawdy dla nawigacji —
// komponenty korzystają z ROUTES zamiast trzymać identyfikatory widoków w stanie.
export const ROUTES = {
home: '/',
buy: '/kupno',
rent: '/wynajem',
sell: '/jak-sprzedawac',
valuation: '/wycena',
priceHistory: '/historia-cen',
districtRanking: '/ranking-dzielnic',
negotiation: '/negocjacje',
comparison: '/konto/porownanie',
add: '/dodaj-ogloszenie',
services: '/firmy-i-uslugi',
companies: '/firmy',
guides: '/poradniki',
guideBuying: '/poradniki/kupno-mieszkania',
creditCalculator: '/kalkulator-zdolnosci',
map: '/mapa',
login: '/logowanie',
account: '/konto',
accountSearches: '/konto/wyszukiwania',
accountPriceAlerts: '/konto/alerty-cenowe',
accountMeetings: '/konto/spotkania',
accountListings: '/konto/moje-oferty',
accountSettings: '/konto/ustawienia',
accountSecurity: '/konto/bezpieczenstwo',
accountProfileEdit: '/konto/profil/edycja',
accountPublicProfile: '/konto/profil',
accountHelpContact: '/konto/pomoc',
notifications: '/powiadomienia',
favorites: '/ulubione',
messages: '/wiadomosci',
admin: '/admin',
listingDetail: '/oferta/:id',
} as const;
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
// Szczegóły oferty: identyfikator trafia do adresu, nie do stanu Reacta.
export function listingPath(id: number): string {
return `/oferta/${id}`;
}
// Negocjacje dotyczą konkretnej oferty — bez identyfikatora pokazujemy pierwszą z listy.
export function negotiationPath(offerId?: number | null): string {
return offerId ? `${ROUTES.negotiation}/${offerId}` : ROUTES.negotiation;
}
// Mapa: miasto i typ oferty jako parametry zapytania, dzięki czemu wynik da się udostępnić.
export function mapPath(city?: string, offerType?: 'SALE' | 'RENT'): string {
const params = new URLSearchParams();
if (city) {
params.set('miasto', city);
}
if (offerType === 'RENT') {
params.set('typ', 'wynajem');
}
const query = params.toString();
return query ? `${ROUTES.map}?${query}` : ROUTES.map;
}
// Lista firm i usług: kategoria w query stringu, dzięki czemu widok da się
// skopiować, odświeżyć i otworzyć w nowej karcie.
export function companiesPath(categorySlug?: string): string {
return categorySlug ? `${ROUTES.companies}?kategoria=${encodeURIComponent(categorySlug)}` : ROUTES.companies;
}
// Zakładki panelu administratora jako podścieżki /admin/*.
export const ADMIN_TAB_PATHS = {
dashboard: '',
listings: 'ogloszenia',
users: 'uzytkownicy',
messages: 'wiadomosci',
reports: 'zgloszenia',
payments: 'platnosci',
stats: 'statystyki',
settings: 'ustawienia',
moderation: 'weryfikacja',
categories: 'kategorie',
locations: 'lokalizacje',
promotions: 'promocje',
forbiddenWords: 'zakazane-slowa',
leads: 'leady',
campaigns: 'kampanie',
mailConfig: 'konfiguracja-wysylki',
} as const;
export type AdminTabKey = keyof typeof ADMIN_TAB_PATHS;
export function adminTabPath(tab: AdminTabKey): string {
const segment = ADMIN_TAB_PATHS[tab];
return segment ? `${ROUTES.admin}/${segment}` : ROUTES.admin;
}
export const ADMIN_PATH_TO_TAB = Object.entries(ADMIN_TAB_PATHS).reduce<Record<string, AdminTabKey>>(
(acc, [tab, segment]) => {
acc[segment] = tab as AdminTabKey;
return acc;
},
{},
);
// Trasy dostępne wyłącznie po zalogowaniu (bramka w ProtectedRoute).
export const PROTECTED_PATHS: string[] = [
ROUTES.add,
ROUTES.account,
ROUTES.accountSearches,
ROUTES.accountPriceAlerts,
ROUTES.accountMeetings,
ROUTES.accountListings,
ROUTES.accountSettings,
ROUTES.accountSecurity,
ROUTES.accountProfileEdit,
ROUTES.accountPublicProfile,
ROUTES.accountHelpContact,
ROUTES.notifications,
ROUTES.favorites,
ROUTES.messages,
ROUTES.comparison,
];
+2126 -47
View File
File diff suppressed because it is too large Load Diff
+249
View File
@@ -0,0 +1,249 @@
import { test, expect, type Page } from '@playwright/test';
import { ROUTES } from '../src/routes';
// Standard projektu (AGENTS.md): kazdy widok ma wlasny URL obslugiwany przez React Router,
// a nawigacja odbywa sie przez <Link>/<NavLink>. Te testy pilnuja tej zasady.
const ADMIN = { email: 'admin@mieszko.pl', password: 'Admin123!' };
const PUBLIC_ROUTES = [
ROUTES.home,
ROUTES.buy,
ROUTES.rent,
ROUTES.sell,
ROUTES.valuation,
ROUTES.priceHistory,
ROUTES.districtRanking,
ROUTES.negotiation,
ROUTES.creditCalculator,
ROUTES.map,
ROUTES.services,
ROUTES.companies,
ROUTES.guides,
ROUTES.guideBuying,
ROUTES.login,
];
const PROTECTED_ROUTES = [
ROUTES.account,
ROUTES.favorites,
ROUTES.messages,
ROUTES.add,
ROUTES.admin,
`${ROUTES.admin}/leady`,
];
async function goto(page: Page, path: string) {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.locator('main').first().waitFor({ state: 'attached' });
// ProtectedRoute dociaga profil z /auth/me - poczekaj, az skonczy stan ladowania
await page.locator('.route-loading').waitFor({ state: 'detached' }).catch(() => {});
await page.waitForTimeout(400);
}
async function login(page: Page) {
await goto(page, ROUTES.login);
await page.getByPlaceholder('Wpisz swój adres e-mail').fill(ADMIN.email);
await page.getByPlaceholder('Wpisz swoje hasło').fill(ADMIN.password);
await page.getByRole('button', { name: 'Zaloguj się', exact: true }).click();
// token w localStorage to pewny dowod zalogowania - adres moze sie jeszcze zmieniac
await page.waitForFunction(() => Boolean(window.localStorage.getItem('polskalokalnie-auth-token')));
await page.waitForFunction(() => window.location.pathname !== '/logowanie');
}
test.describe('Trasy publiczne', () => {
for (const path of PUBLIC_ROUTES) {
test(`bezposrednie wejscie na ${path} (F5 / nowa karta / wklejony link)`, async ({ page }) => {
await goto(page, path);
expect(new URL(page.url()).pathname).toBe(path);
await expect(page.locator('main')).not.toBeEmpty();
});
}
test('nieznany adres pokazuje strone 404', async ({ page }) => {
await goto(page, '/adres-ktory-nie-istnieje');
await expect(page.locator('.not-found-page')).toBeVisible();
});
test('odswiezenie strony zachowuje widok', async ({ page }) => {
await goto(page, ROUTES.districtRanking);
await page.reload({ waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe(ROUTES.districtRanking);
});
});
test.describe('Lista firm i uslug (/firmy)', () => {
test('wejscie na /firmy pokazuje pelna liste', async ({ page }) => {
await goto(page, ROUTES.companies);
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
const cards = await page.locator('.directory-card').count();
expect(cards).toBeGreaterThan(10);
});
test('filtr kategorii dziala przez query string', async ({ page }) => {
await goto(page, `${ROUTES.companies}?kategoria=remonty`);
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
const filtered = await page.locator('.directory-card').count();
await goto(page, ROUTES.companies);
const all = await page.locator('.directory-card').count();
expect(filtered).toBeGreaterThan(0);
expect(filtered).toBeLessThan(all);
});
test('nieznana kategoria nie wywraca widoku - pokazuje pelna liste', async ({ page }) => {
await goto(page, `${ROUTES.companies}?kategoria=nie-ma-takiej`);
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
});
test('klik w chip kategorii zmienia adres i wynik', async ({ page }) => {
await goto(page, ROUTES.companies);
await page.locator('.companies-chip', { hasText: 'Finanse' }).first().click();
await page.waitForFunction(() => window.location.search.includes('kategoria=finanse'));
await expect(page.locator('.companies-head h1')).toHaveText('Finanse');
});
test('"Zobacz firmy" prowadzi do listy z wybrana kategoria', async ({ page }) => {
await goto(page, ROUTES.services);
const link = page.locator('.service-category-card', { hasText: 'Remont i wykończenie' }).getByRole('link');
await expect(link).toHaveAttribute('href', '/firmy?kategoria=remonty');
await link.click();
await page.waitForFunction(() => window.location.pathname === '/firmy');
await expect(page.locator('.companies-head h1')).toHaveText('Remont i wykończenie');
});
test('"Zobacz wszystkie" prowadzi do pelnej listy', async ({ page }) => {
await goto(page, ROUTES.services);
const link = page.locator('.services-section .section-head', { hasText: 'Polecane firmy' }).getByRole('link');
await expect(link).toHaveAttribute('href', ROUTES.companies);
await link.click();
await page.waitForFunction(() => window.location.pathname === '/firmy');
await expect(page.locator('.companies-head h1')).toHaveText('Wszystkie firmy i usługi');
});
test('Wstecz i Dalej dzialaja na filtrze kategorii', async ({ page }) => {
await goto(page, ROUTES.services);
await page.locator('.service-category-card', { hasText: 'Finanse' }).getByRole('link').click();
await page.waitForFunction(() => window.location.search.includes('kategoria=finanse'));
await page.goBack({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.location.pathname === '/firmy-i-uslugi');
await page.goForward({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.location.search.includes('kategoria=finanse'));
});
});
test.describe('Nawigacja przez linki', () => {
test('menu glowne to <a href> - mozna otworzyc w nowej karcie', async ({ page }) => {
await goto(page, ROUTES.home);
for (const [label, expected] of [
['Kupuję', ROUTES.buy],
['Wynajmuję', ROUTES.rent],
['Poradniki', ROUTES.guides],
['Firmy i usługi', ROUTES.services],
] as const) {
const link = page.getByRole('link', { name: label, exact: true }).first();
await expect(link).toHaveAttribute('href', expected);
}
});
test('aktywna pozycja menu wynika z adresu', async ({ page }) => {
await goto(page, ROUTES.buy);
await expect(page.getByRole('link', { name: 'Kupuję', exact: true }).first()).toHaveClass(/active/);
});
test('CTA naglowka maja poprawne adresy', async ({ page }) => {
await goto(page, ROUTES.home);
await expect(page.locator('a.post-button')).toHaveAttribute('href', ROUTES.add);
await expect(page.getByRole('link', { name: 'Moje konto' })).toHaveAttribute('href', ROUTES.login);
});
test('CTA w tresci strony sa linkami z href', async ({ page }) => {
await goto(page, ROUTES.sell);
await expect(page.locator('a.sell-primary')).toHaveAttribute('href', ROUTES.add);
await expect(page.locator('.sell-valuation-card a')).toHaveAttribute('href', ROUTES.valuation);
});
test('karta oferty ma prawdziwy link do strony oferty', async ({ page }) => {
await goto(page, ROUTES.buy);
await page.locator('.real-result-card').first().waitFor();
const card = page.locator('.real-result-card').first();
const href = await card.locator('.card-stretched-link').getAttribute('href');
expect(href).toMatch(/^\/oferta\/\d+$/);
await expect(card.locator('a.real-card-cta')).toHaveAttribute('href', href!);
});
test('klik w serce na karcie nie nawiguje', async ({ page }) => {
await goto(page, ROUTES.buy);
await page.locator('.real-result-card').first().waitFor();
await page.locator('.real-result-card').first().locator('.real-favorite-button').click();
await page.waitForTimeout(700);
expect(new URL(page.url()).pathname).toBe(ROUTES.buy);
});
test('klik w menu zmienia adres, Wstecz i Dalej dzialaja', async ({ page }) => {
await goto(page, ROUTES.home);
await page.getByRole('link', { name: 'Kupuję', exact: true }).first().click();
await page.waitForFunction(() => window.location.pathname === '/kupno');
await page.getByRole('link', { name: 'Poradniki', exact: true }).first().click();
await page.waitForFunction(() => window.location.pathname === '/poradniki');
await page.goBack({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.location.pathname === '/kupno');
await page.goForward({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.location.pathname === '/poradniki');
});
});
test.describe('Standard: brak atrap linkow', () => {
const PAGES = [ROUTES.home, ROUTES.services, ROUTES.companies, ROUTES.guides, ROUTES.sell, ROUTES.buy];
for (const path of PAGES) {
test(`${path} nie zawiera href="#" ani javascript:void`, async ({ page }) => {
await goto(page, path);
const bad = await page.locator('a[href="#"], a[href^="javascript:"]').count();
expect(bad).toBe(0);
});
}
});
test.describe('Trasy chronione', () => {
test('bez logowania przekierowuja na /logowanie', async ({ page }) => {
for (const path of PROTECTED_ROUTES) {
await goto(page, path);
expect(new URL(page.url()).pathname).toBe(ROUTES.login);
}
});
test('po zalogowaniu wracamy na zamierzona strone', async ({ page }) => {
await goto(page, ROUTES.accountPriceAlerts);
expect(new URL(page.url()).pathname).toBe(ROUTES.login);
await page.getByPlaceholder('Wpisz swój adres e-mail').fill(ADMIN.email);
await page.getByPlaceholder('Wpisz swoje hasło').fill(ADMIN.password);
await page.getByRole('button', { name: 'Zaloguj się', exact: true }).click();
await page.waitForFunction(() => window.location.pathname === '/konto/alerty-cenowe');
});
test('zakladki panelu admina maja wlasne adresy i przezywaja F5', async ({ page }) => {
await login(page);
await goto(page, ROUTES.admin);
await page.locator('.admin-nav a', { hasText: 'Leady' }).first().click();
await page.waitForFunction(() => window.location.pathname === '/admin/leady');
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.admin-nav').first().waitFor({ state: 'attached' });
expect(new URL(page.url()).pathname).toBe('/admin/leady');
});
test('nieznana sekcja panelu admina pokazuje 404', async ({ page }) => {
await login(page);
await goto(page, `${ROUTES.admin}/nie-ma-takiej-sekcji`);
await expect(page.locator('.not-found-page')).toBeVisible();
});
});