Compare commits
11 Commits
498ea07b9e
...
0ec73959a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ec73959a7 | |||
| 3e394998a3 | |||
| e1ab61ec7c | |||
| f966b4539a | |||
| 5f5bec7232 | |||
| af21991d9f | |||
| ec5a7289f6 | |||
| f072ed0115 | |||
| a6b156695f | |||
| 12fcb60b4c | |||
| e9673551ce |
@@ -10,6 +10,8 @@ backend/target/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Generated
+123
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
+2029
-483
File diff suppressed because it is too large
Load Diff
@@ -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 />;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<NotificationsProvider>
|
||||
<App />
|
||||
</NotificationsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user