sms & SMTP
This commit is contained in:
@@ -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 {
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user