diff --git a/backend/pom.xml b/backend/pom.xml index c0bc397..5e07ec0 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -38,6 +38,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-mail + io.jsonwebtoken jjwt-api diff --git a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java index 39c5476..c48c380 100644 --- a/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java +++ b/backend/src/main/java/pl/polskalokalnie/PolskaLokalnieApplication.java @@ -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 { diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java index 69341f0..8f9bddd 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AuthService.java @@ -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) { diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index 12f9e3b..6769d69 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -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() diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java b/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java new file mode 100644 index 0000000..c272deb --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/Campaign.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java new file mode 100644 index 0000000..6ce6e6c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignController.java @@ -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 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 delete(@PathVariable Long id) { + campaignService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java new file mode 100644 index 0000000..5a3f495 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipient.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java new file mode 100644 index 0000000..b3a8647 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRecipientRepository.java @@ -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 { + + List findByCampaignId(Long campaignId); + + long countByCampaignIdAndStatus(Long campaignId, RecipientStatus status); + + List findByCampaignIdAndStatusOrderByIdAsc(Long campaignId, RecipientStatus status, Limit limit); + + boolean existsByCampaignId(Long campaignId); +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java new file mode 100644 index 0000000..e84620c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRepository.java @@ -0,0 +1,9 @@ +package pl.polskalokalnie.campaign; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CampaignRepository extends JpaRepository { + + List findByStatus(CampaignStatus status); +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java new file mode 100644 index 0000000..7ebf1d6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java new file mode 100644 index 0000000..822f49a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignResponse.java @@ -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 + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java new file mode 100644 index 0000000..081d78c --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignService.java @@ -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 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 members = leadQueryService.resolve(group); + Set 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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java new file mode 100644 index 0000000..1e258f4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatsResponse.java @@ -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 +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java new file mode 100644 index 0000000..bf1d074 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/CampaignStatus.java @@ -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 +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java b/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java new file mode 100644 index 0000000..cc5aa1e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/Channel.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.campaign; + +public enum Channel { + EMAIL, + SMS +} diff --git a/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java b/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java new file mode 100644 index 0000000..d1b0c50 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/campaign/RecipientStatus.java @@ -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 +} diff --git a/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java b/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java new file mode 100644 index 0000000..0c1eebd --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/config/LeadBackfillRunner.java @@ -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); + } + }); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java new file mode 100644 index 0000000..dd79db3 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/ImportResultResponse.java @@ -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 messages +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/Lead.java b/backend/src/main/java/pl/polskalokalnie/lead/Lead.java new file mode 100644 index 0000000..8f9ce06 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/Lead.java @@ -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 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 getTags() { + return tags; + } + + public void setTags(List 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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java new file mode 100644 index 0000000..dfa9c87 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadController.java @@ -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 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 delete(@PathVariable Long id) { + leadService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java new file mode 100644 index 0000000..35bc08b --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadCreateRequest.java @@ -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 tags, + Boolean emailConsent, + Boolean smsConsent, + String notes +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java new file mode 100644 index 0000000..b744cf6 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadImportRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java new file mode 100644 index 0000000..ea310c1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadQueryService.java @@ -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 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 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(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java new file mode 100644 index 0000000..41353a4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadRepository.java @@ -0,0 +1,15 @@ +package pl.polskalokalnie.lead; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LeadRepository extends JpaRepository { + + Optional findByUserId(Long userId); + + Optional findFirstByEmailIgnoreCase(String email); + + Optional findFirstByPhone(String phone); + + boolean existsByEmailIgnoreCase(String email); +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java new file mode 100644 index 0000000..45b0576 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadResponse.java @@ -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 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() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java new file mode 100644 index 0000000..c43add4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadService.java @@ -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 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 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 cleanTags(List tags) { + List cleaned = new ArrayList<>(); + if (tags != null) { + Set 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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java new file mode 100644 index 0000000..6958e36 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadSource.java @@ -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 +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java new file mode 100644 index 0000000..cc9c57d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadStatus.java @@ -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 +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java new file mode 100644 index 0000000..6e208e1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadSyncService.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java new file mode 100644 index 0000000..2ade1ff --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/LeadUpdateRequest.java @@ -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 tags, + Boolean emailConsent, + Boolean smsConsent, + Boolean emailOptOut, + Boolean smsOptOut, + String notes +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java b/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java new file mode 100644 index 0000000..7bc69fc --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/MarketingConsentController.java @@ -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() + ); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java new file mode 100644 index 0000000..3058aab --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroup.java @@ -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 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 getSources() { + return sources; + } + + public void setSources(Set 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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java new file mode 100644 index 0000000..9e77d26 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupController.java @@ -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 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 delete(@PathVariable Long id) { + targetGroupService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java new file mode 100644 index 0000000..2372d85 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupPreviewResponse.java @@ -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 sample +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java new file mode 100644 index 0000000..79d1e88 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.lead; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TargetGroupRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java new file mode 100644 index 0000000..25951c2 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupRequest.java @@ -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 sources, + String city, + String tag, + LeadStatus status, + boolean requireEmail, + boolean requirePhone, + boolean requireEmailConsent, + boolean requireSmsConsent +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java new file mode 100644 index 0000000..1b8917d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupResponse.java @@ -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 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() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java new file mode 100644 index 0000000..72c9662 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/lead/TargetGroupService.java @@ -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 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 members = leadQueryService.resolve(group); + List 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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java new file mode 100644 index 0000000..7c85161 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigController.java @@ -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", + "

To jest wiadomość testowa potwierdzająca poprawną konfigurację poczty SMTP.

"); + 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()); + } + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java new file mode 100644 index 0000000..baa314a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/MailConfigService.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java new file mode 100644 index 0000000..7b76afa --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java new file mode 100644 index 0000000..de6dafb --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsConfigResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java new file mode 100644 index 0000000..1357b88 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java new file mode 100644 index 0000000..cd2d90d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmsGatewayConfigRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.mailconfig; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SmsGatewayConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java new file mode 100644 index 0000000..9cd39e5 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java new file mode 100644 index 0000000..bbaa406 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRepository.java @@ -0,0 +1,6 @@ +package pl.polskalokalnie.mailconfig; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface SmtpConfigRepository extends JpaRepository { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java new file mode 100644 index 0000000..c15cc26 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigRequest.java @@ -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 +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java new file mode 100644 index 0000000..5cd2da4 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/mailconfig/SmtpConfigResponse.java @@ -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() + ); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java b/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java new file mode 100644 index 0000000..26d1caa --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/CampaignDispatcher.java @@ -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 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); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java b/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java new file mode 100644 index 0000000..d1c9759 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/EmailSender.java @@ -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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java b/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java new file mode 100644 index 0000000..bf9b796 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/MessageComposer.java @@ -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 = "
" + + "

" + + "Otrzymujesz tę wiadomość, ponieważ wyraziłeś zgodę na kontakt marketingowy. " + + "Zrezygnuj z otrzymywania wiadomości.

"; + return "
" + html + footer + "
"; + } + + /** 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", "
"); + } + + private static String escapeHtml(String value) { + return value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">"); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/SendResult.java b/backend/src/main/java/pl/polskalokalnie/send/SendResult.java new file mode 100644 index 0000000..9677de7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/SendResult.java @@ -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); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java new file mode 100644 index 0000000..506f8d1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/send/SmsSender.java @@ -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 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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java new file mode 100644 index 0000000..bfcbe42 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeController.java @@ -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 "" + + "" + + "" + title + "" + + "" + + "

" + title + "

" + message + "

"; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java new file mode 100644 index 0000000..c2c80b8 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/unsubscribe/UnsubscribeService.java @@ -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) { + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 3ffcf1f..368e451 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -19,6 +19,8 @@ spring: format_sql: true app: + # Publiczny adres bazowy uzywany do budowy linkow (np. rezygnacja z kampanii). + public-base-url: ${APP_PUBLIC_BASE_URL:http://localhost} jwt: # W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa. secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05528d2..c71a078 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import type { AuthUser, ContactPreference, PreferredLanguage } from './auth'; import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; +import { AdminLeadsView, AdminCampaignsView, AdminMailConfigView } from './admin/marketing'; const heroImage = new URL('./assets/hero-interior.png', import.meta.url).href; const cityImage = new URL('./assets/city-panorama.png', import.meta.url).href; @@ -3636,7 +3637,8 @@ function accountStorageKey(suffix: string, user: AuthUser | null): string { type AdminTab = | 'dashboard' | 'listings' | 'users' | 'messages' | 'reports' | 'payments' - | 'stats' | 'settings' | 'moderation' | 'categories' | 'locations' | 'promotions' | 'forbiddenWords'; + | 'stats' | 'settings' | 'moderation' | 'categories' | 'locations' | 'promotions' | 'forbiddenWords' + | 'leads' | 'campaigns' | 'mailConfig'; const ADMIN_TAB_META: Record = { dashboard: { title: 'Panel administratora', subtitle: 'Zarządzaj ogłoszeniami i użytkownikami serwisu.' }, @@ -3652,6 +3654,9 @@ const ADMIN_TAB_META: Record = { locations: { title: 'Lokalizacje', subtitle: 'Zarządzaj obsługiwanymi miastami i dzielnicami.' }, promotions: { title: 'Banery i promocje', subtitle: 'Zarządzaj banerami reklamowymi i promocjami.' }, forbiddenWords: { title: 'Zakazane słowa', subtitle: 'Lista wszystkich niedozwolonych słów używanych przez globalny filtr treści.' }, + leads: { title: 'Leady', subtitle: 'Baza kontaktów marketingowych i grupy docelowe (dynamiczne segmenty).' }, + campaigns: { title: 'Kampanie marketingowe', subtitle: 'Twórz i wysyłaj kampanie e-mail oraz SMS do grup docelowych.' }, + mailConfig: { title: 'Konfiguracja wysyłki', subtitle: 'Ustawienia poczty SMTP i bramki SMS dla kampanii i powiadomień.' }, }; const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ @@ -3660,6 +3665,8 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'users', icon: 'user', label: 'Użytkownicy' }, { id: 'messages', icon: 'message', label: 'Wiadomości' }, { id: 'reports', icon: 'warning', label: 'Zgłoszenia' }, + { id: 'leads', icon: 'user', label: 'Leady' }, + { id: 'campaigns', icon: 'mail', label: 'Kampanie marketingowe' }, { id: 'payments', icon: 'credit-card', label: 'Płatności' }, { id: 'stats', icon: 'chart', label: 'Statystyki' }, { id: 'settings', icon: 'gear', label: 'Ustawienia serwisu' }, @@ -3667,6 +3674,7 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ const ADMIN_TOOL_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'moderation', icon: 'check', label: 'Weryfikacja ogłoszeń' }, + { id: 'mailConfig', icon: 'gear', label: 'Konfiguracja wysyłki' }, { id: 'forbiddenWords', icon: 'shield', label: 'Zakazane słowa' }, { id: 'categories', icon: 'list', label: 'Kategorie' }, { id: 'locations', icon: 'pin', label: 'Lokalizacje' }, @@ -5028,6 +5036,9 @@ function AdminPage({ onNavigate }: { onNavigate: (view: View) => void }) { {!loading && tab === 'categories' && } {!loading && tab === 'locations' && } {!loading && tab === 'promotions' && } + {!loading && tab === 'leads' && } + {!loading && tab === 'campaigns' && } + {!loading && tab === 'mailConfig' && } @@ -8806,16 +8817,52 @@ function AccountListingsPage({ onNavigate, activeView, onOpenListing }: { onNavi function AccountSettingsPage({ onNavigate, activeView }: { onNavigate: (view: View) => void; activeView: View }) { const { user } = useAuth(); const [accountSwitches, setAccountSwitches] = useState([true, true, false, false]); + // Indeks 4 ("Oferty promowane i newsletter") to zgoda marketingowa e-mail zapisywana na serwerze. const [mailSwitches, setMailSwitches] = useState([true, true, true, false, false]); + const [smsConsent, setSmsConsent] = useState(true); const address = user?.address ?? ''; const birthDate = user?.birthDate ?? ''; + // Wczytaj aktualną zgodę marketingową użytkownika (steruje kwalifikacją do kampanii). + useEffect(() => { + if (!user) { + return; + } + let active = true; + apiFetch<{ emailConsent: boolean; smsConsent: boolean }>('/me/marketing') + .then((consent) => { + if (!active) { + return; + } + setSmsConsent(consent.smsConsent); + setMailSwitches((current) => current.map((value, idx) => (idx === 4 ? consent.emailConsent : value))); + }) + .catch(() => { + // Brak zgody/sesji - zostawiamy wartości domyślne. + }); + return () => { + active = false; + }; + }, [user]); + const toggleAccountSwitch = (index: number) => { setAccountSwitches((current) => current.map((value, idx) => (idx === index ? !value : value))); }; const toggleMailSwitch = (index: number) => { - setMailSwitches((current) => current.map((value, idx) => (idx === index ? !value : value))); + setMailSwitches((current) => { + const next = current.map((value, idx) => (idx === index ? !value : value)); + // Zgodę marketingową e-mail (indeks 4) utrwalamy na serwerze. + if (index === 4) { + apiFetch('/me/marketing', { + method: 'PUT', + body: JSON.stringify({ emailConsent: next[4], smsConsent }), + }).catch(() => { + // Błąd zapisu nie blokuje UI; wartość zostanie zsynchronizowana przy kolejnym wejściu. + }); + } + return next; + }); }; return ( diff --git a/frontend/src/admin/marketing.tsx b/frontend/src/admin/marketing.tsx new file mode 100644 index 0000000..4da6dbd --- /dev/null +++ b/frontend/src/admin/marketing.tsx @@ -0,0 +1,974 @@ +/** + * Widoki panelu administratora dla modułu marketingowego: Leady + grupy docelowe, Kampanie oraz + * konfiguracja poczty SMTP i bramki SMS. Wydzielone z App.tsx (monolit 22k linii), aby go nie + * powiększać. Komponent `Icon` przekazywany jest propem, żeby uniknąć cyklicznego importu. + */ +import { useCallback, useEffect, useState } from 'react'; +import { apiFetch } from '../auth'; + +type IconComponent = (props: { name: string }) => JSX.Element | null; + +type LeadSource = 'REGISTERED_USER' | 'MANUAL' | 'IMPORT' | 'FORM'; +type LeadStatus = 'NEW' | 'CONTACTED' | 'RESPONDED' | 'UNSUBSCRIBED'; + +type Lead = { + id: number; + name: string | null; + email: string | null; + phone: string | null; + city: string | null; + source: LeadSource; + userId: number | null; + status: LeadStatus; + tags: string[]; + emailConsent: boolean; + smsConsent: boolean; + emailOptOut: boolean; + smsOptOut: boolean; + notes: string | null; + createdAt: string; +}; + +type TargetGroup = { + id: number; + name: string; + description: string | null; + sources: LeadSource[]; + city: string | null; + tag: string | null; + status: LeadStatus | null; + requireEmail: boolean; + requirePhone: boolean; + requireEmailConsent: boolean; + requireSmsConsent: boolean; + memberCount: number; + createdAt: string; +}; + +type Channel = 'EMAIL' | 'SMS'; +type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'; + +type Campaign = { + id: number; + name: string; + channel: Channel; + subject: string | null; + body: string; + targetGroupId: number; + scheduledAt: string | null; + dailyLimit: number; + status: CampaignStatus; + sentToday: number; + createdAt: string; + createdBy: string | null; + totalRecipients: number; + sentCount: number; +}; + +type CampaignStats = { + total: number; + queued: number; + sent: number; + delivered: number; + failed: number; + bounced: number; + optedOut: number; +}; + +const SOURCE_LABEL: Record = { + REGISTERED_USER: 'Użytkownik', + MANUAL: 'Ręczny', + IMPORT: 'Import', + FORM: 'Formularz', +}; + +const STATUS_LABEL: Record = { + NEW: 'Nowy', + CONTACTED: 'Kontakt', + RESPONDED: 'Odpowiedział', + UNSUBSCRIBED: 'Wypisany', +}; + +const CAMPAIGN_STATUS_LABEL: Record = { + DRAFT: 'Robocza', + SCHEDULED: 'Zaplanowana', + SENDING: 'Wysyłka', + PAUSED: 'Wstrzymana', + COMPLETED: 'Zakończona', + CANCELLED: 'Anulowana', +}; + +const ALL_SOURCES: LeadSource[] = ['REGISTERED_USER', 'MANUAL', 'IMPORT', 'FORM']; + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : 'Wystąpił błąd. Spróbuj ponownie.'; +} + +/* ============================ LEADY + GRUPY DOCELOWE ============================ */ + +export function AdminLeadsView({ Icon }: { Icon: IconComponent }) { + const [section, setSection] = useState<'leads' | 'groups'>('leads'); + return ( +
+
+ + +
+ {section === 'leads' ? : } +
+ ); +} + +function LeadsSection({ Icon }: { Icon: IconComponent }) { + const [leads, setLeads] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [sourceFilter, setSourceFilter] = useState<'' | LeadSource>(''); + const [search, setSearch] = useState(''); + const [busyId, setBusyId] = useState(null); + + // Formularz nowego leada. + const [form, setForm] = useState({ name: '', email: '', phone: '', city: '', tags: '' }); + const [saving, setSaving] = useState(false); + + // Import. + const [csv, setCsv] = useState(''); + const [importTag, setImportTag] = useState(''); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (sourceFilter) params.set('source', sourceFilter); + if (search.trim()) params.set('search', search.trim()); + const data = await apiFetch(`/admin/leads${params.toString() ? `?${params.toString()}` : ''}`); + setLeads(data); + } catch (err) { + setError(errorMessage(err)); + } finally { + setLoading(false); + } + }, [sourceFilter, search]); + + useEffect(() => { + const timer = setTimeout(load, 200); + return () => clearTimeout(timer); + }, [load]); + + const addLead = async () => { + if (!form.email.trim() && !form.phone.trim()) { + setError('Podaj co najmniej e-mail lub telefon.'); + return; + } + setSaving(true); + setError(null); + try { + await apiFetch('/admin/leads', { + method: 'POST', + body: JSON.stringify({ + name: form.name.trim() || null, + email: form.email.trim() || null, + phone: form.phone.trim() || null, + city: form.city.trim() || null, + tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean), + }), + }); + setForm({ name: '', email: '', phone: '', city: '', tags: '' }); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setSaving(false); + } + }; + + const runImport = async () => { + if (!csv.trim()) return; + setImporting(true); + setImportResult(null); + setError(null); + try { + const res = await apiFetch<{ imported: number; skipped: number; messages: string[] }>( + '/admin/leads/import', + { method: 'POST', body: JSON.stringify({ csv, defaultTag: importTag.trim() || null }) }, + ); + setImportResult(`Dodano ${res.imported}, pominięto ${res.skipped}.`); + setCsv(''); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setImporting(false); + } + }; + + const optOut = async (lead: Lead) => { + setBusyId(lead.id); + try { + await apiFetch(`/admin/leads/${lead.id}`, { + method: 'PATCH', + body: JSON.stringify({ emailOptOut: true, smsOptOut: true }), + }); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusyId(null); + } + }; + + const remove = async (lead: Lead) => { + setBusyId(lead.id); + try { + await apiFetch(`/admin/leads/${lead.id}`, { method: 'DELETE' }); + await load(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusyId(null); + } + }; + + return ( +
+
+

Baza leadów

+ {leads.length} kontaktów +
+ +
+
+ + setSearch(e.target.value)} + placeholder="Szukaj po nazwie, e-mailu, telefonie, mieście..." + /> +
+ +
+ +
+ setForm({ ...form, name: e.target.value })} /> + setForm({ ...form, email: e.target.value })} /> + setForm({ ...form, phone: e.target.value })} /> + setForm({ ...form, city: e.target.value })} /> + setForm({ ...form, tags: e.target.value })} /> + +
+ + {error &&

{error}

} + + {loading ? ( +

Ładowanie...

+ ) : leads.length === 0 ? ( +

Brak leadów. Dodaj kontakt lub zaimportuj listę.

+ ) : ( +
+ + + + + + + + {leads.map((lead) => ( + + + + + + + + + + ))} + +
NazwaKontaktMiastoŹródłoStatusZgody
{lead.name || '—'} +
+ {lead.email && {lead.email}} + {lead.phone && {lead.phone}} +
+
{lead.city || '—'}{SOURCE_LABEL[lead.source]}{STATUS_LABEL[lead.status]} +
+ @ + SMS +
+
+ {!(lead.emailOptOut && lead.smsOptOut) && ( + + )} + +
+
+ )} + +
+

Import kontaktów (CSV)

+

Kolumny: nazwa;email;telefon;miasto — jeden kontakt w wierszu, nagłówek opcjonalny.

+