analytics

This commit is contained in:
root
2025-09-22 17:42:15 +05:00
parent 005cd0d906
commit 66956c3769
43 changed files with 2321 additions and 0 deletions
@@ -2,8 +2,10 @@ package kz.konturai;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class KonturaiApplication {
public static void main(String[] args) {
@@ -1,6 +1,7 @@
package kz.konturai.configuration;
import java.util.NoSuchElementException;
import kz.konturai.exception.SocialApiException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
@@ -55,6 +56,11 @@ public class GlobalExceptionHandler {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse("Forbidden"));
}
@ExceptionHandler(SocialApiException.class)
public ResponseEntity<ErrorResponse> handleSocialApi(SocialApiException ex) {
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Internal server error"));
@@ -0,0 +1,27 @@
package kz.konturai.controller;
import java.util.Map;
import kz.konturai.service.spec.AnalyticsService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
// Optional admin/debug controller
@RestController
@RequestMapping("/api/smm/analytics")
public class AnalyticsController {
private final AnalyticsService analyticsService;
public AnalyticsController(AnalyticsService analyticsService) {
this.analyticsService = analyticsService;
}
@PostMapping("/collect-now")
public Map<String, Object> collectNow() {
analyticsService.collectAndSaveAnalytics();
return Map.of(
"success", true,
"message", "Сбор аналитики запущен.");
}
}
@@ -0,0 +1,56 @@
package kz.konturai.controller;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import kz.konturai.dto.CampaignDto;
import kz.konturai.dto.CreateCampaignRequest;
import kz.konturai.service.spec.CampaignService;
import org.springframework.http.HttpStatus;
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;
@RestController
@RequestMapping("/api/smm/campaigns")
public class CampaignController {
private final CampaignService campaignService;
public CampaignController(CampaignService campaignService) {
this.campaignService = campaignService;
}
@GetMapping
public List<CampaignDto> getAll() {
return campaignService.getAllCampaigns();
}
@GetMapping("/{id}")
public CampaignDto getById(@PathVariable UUID id) {
return campaignService.getCampaignById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public CampaignDto create(@Valid @RequestBody CreateCampaignRequest request) {
return campaignService.createCampaign(request);
}
@PutMapping("/{id}")
public CampaignDto update(@PathVariable UUID id, @Valid @RequestBody CreateCampaignRequest request) {
return campaignService.updateCampaign(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID id) {
campaignService.deleteCampaign(id);
}
}
@@ -0,0 +1,56 @@
package kz.konturai.controller;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import kz.konturai.dto.ChannelDto;
import kz.konturai.dto.CreateChannelRequest;
import kz.konturai.service.spec.ChannelService;
import org.springframework.http.HttpStatus;
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;
@RestController
@RequestMapping("/api/smm/channels")
public class ChannelController {
private final ChannelService channelService;
public ChannelController(ChannelService channelService) {
this.channelService = channelService;
}
@GetMapping
public List<ChannelDto> getAll() {
return channelService.getAllChannels();
}
@GetMapping("/{id}")
public ChannelDto getById(@PathVariable UUID id) {
return channelService.getChannelById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ChannelDto create(@Valid @RequestBody CreateChannelRequest request) {
return channelService.createChannel(request);
}
@PutMapping("/{id}")
public ChannelDto update(@PathVariable UUID id, @Valid @RequestBody CreateChannelRequest request) {
return channelService.updateChannel(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID id) {
channelService.deleteChannel(id);
}
}
@@ -0,0 +1,67 @@
package kz.konturai.controller;
import jakarta.validation.Valid;
import java.util.List;
import java.util.UUID;
import kz.konturai.dto.ContentQueueDto;
import kz.konturai.dto.CreateContentRequest;
import kz.konturai.dto.MessageDto;
import kz.konturai.service.spec.ContentService;
import org.springframework.http.HttpStatus;
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;
@RestController
@RequestMapping("/api/smm/content")
public class ContentController {
private final ContentService contentService;
public ContentController(ContentService contentService) {
this.contentService = contentService;
}
@GetMapping
public List<ContentQueueDto> list() {
return contentService.list();
}
@GetMapping("/{id}")
public ContentQueueDto get(@PathVariable UUID id) {
return contentService.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ContentQueueDto create(@Valid @RequestBody CreateContentRequest request) {
return contentService.create(request);
}
@PutMapping("/{id}")
public ContentQueueDto update(@PathVariable UUID id, @Valid @RequestBody CreateContentRequest request) {
return contentService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable UUID id) {
contentService.delete(id);
}
@PostMapping("/{id}/approve")
public ContentQueueDto approve(@PathVariable UUID id) {
return contentService.approve(id);
}
@GetMapping("/{id}/messages")
public List<MessageDto> listMessages(@PathVariable UUID id) {
return contentService.listMessages(id);
}
}
@@ -0,0 +1,32 @@
package kz.konturai.controller;
import java.util.Map;
import java.util.UUID;
import kz.konturai.domain.Message;
import kz.konturai.service.spec.PublishingService;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/smm/publishing")
public class PublishingController {
private final PublishingService publishingService;
public PublishingController(PublishingService publishingService) {
this.publishingService = publishingService;
}
@PostMapping("/post/{contentId}")
public Map<String, Object> publish(@PathVariable UUID contentId) {
Message m = publishingService.publishContentById(contentId);
return Map.of(
"success", true,
"message", "Пост успешно опубликован",
"data", Map.of(
"messageId", m.getId(),
"externalUrl", m.getUrl()));
}
}
@@ -0,0 +1,98 @@
package kz.konturai.domain;
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.math.BigDecimal;
import java.time.ZonedDateTime;
import java.util.UUID;
import kz.konturai.domain.enums.CampaignStatus;
@Entity
@Table(name = "campaigns")
public class Campaign {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(nullable = false)
private String name;
@Column
private String goal;
@Column
private BigDecimal budget;
@Column
private ZonedDateTime startAt;
@Column
private ZonedDateTime endAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private CampaignStatus status;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getGoal() {
return goal;
}
public void setGoal(String goal) {
this.goal = goal;
}
public BigDecimal getBudget() {
return budget;
}
public void setBudget(BigDecimal budget) {
this.budget = budget;
}
public ZonedDateTime getStartAt() {
return startAt;
}
public void setStartAt(ZonedDateTime startAt) {
this.startAt = startAt;
}
public ZonedDateTime getEndAt() {
return endAt;
}
public void setEndAt(ZonedDateTime endAt) {
this.endAt = endAt;
}
public CampaignStatus getStatus() {
return status;
}
public void setStatus(CampaignStatus status) {
this.status = status;
}
}
@@ -0,0 +1,74 @@
package kz.konturai.domain;
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.util.UUID;
import kz.konturai.domain.enums.ChannelType;
@Entity
@Table(name = "channels")
public class Channel {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(nullable = false)
private String name;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ChannelType type;
@Column
private String apiKeyRef;
@Column(nullable = false)
private boolean isActive;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public ChannelType getType() {
return type;
}
public void setType(ChannelType type) {
this.type = type;
}
public String getApiKeyRef() {
return apiKeyRef;
}
public void setApiKeyRef(String apiKeyRef) {
this.apiKeyRef = apiKeyRef;
}
public boolean isActive() {
return isActive;
}
public void setActive(boolean active) {
isActive = active;
}
}
@@ -0,0 +1,127 @@
package kz.konturai.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.ZonedDateTime;
import java.util.UUID;
import kz.konturai.domain.enums.ContentStatus;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
@Entity
@Table(name = "content_queue")
public class ContentQueue {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "campaign_id")
private Campaign campaign;
@Column
private String locale;
@Column
private String topic;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private String postDraft;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private String assetsRefs;
@Column
private ZonedDateTime scheduledAt;
@Column
private int priority;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ContentStatus status;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public Campaign getCampaign() {
return campaign;
}
public void setCampaign(Campaign campaign) {
this.campaign = campaign;
}
public String getLocale() {
return locale;
}
public void setLocale(String locale) {
this.locale = locale;
}
public String getTopic() {
return topic;
}
public void setTopic(String topic) {
this.topic = topic;
}
public String getPostDraft() {
return postDraft;
}
public void setPostDraft(String postDraft) {
this.postDraft = postDraft;
}
public String getAssetsRefs() {
return assetsRefs;
}
public void setAssetsRefs(String assetsRefs) {
this.assetsRefs = assetsRefs;
}
public ZonedDateTime getScheduledAt() {
return scheduledAt;
}
public void setScheduledAt(ZonedDateTime scheduledAt) {
this.scheduledAt = scheduledAt;
}
public int getPriority() {
return priority;
}
public void setPriority(int priority) {
this.priority = priority;
}
public ContentStatus getStatus() {
return status;
}
public void setStatus(ContentStatus status) {
this.status = status;
}
}
@@ -0,0 +1,76 @@
package kz.konturai.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
@Entity
@Table(name = "kpi_snapshots")
public class KpiSnapshot {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@Column(nullable = false)
private LocalDate date;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id")
private Channel channel;
@Column(nullable = false)
private String metric;
@Column(nullable = false)
private BigDecimal value;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public Channel getChannel() {
return channel;
}
public void setChannel(Channel channel) {
this.channel = channel;
}
public String getMetric() {
return metric;
}
public void setMetric(String metric) {
this.metric = metric;
}
public BigDecimal getValue() {
return value;
}
public void setValue(BigDecimal value) {
this.value = value;
}
}
@@ -0,0 +1,88 @@
package kz.konturai.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.time.ZonedDateTime;
import java.util.UUID;
@Entity
@Table(name = "messages")
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "channel_id")
private Channel channel;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "content_id")
private ContentQueue content;
@Column
private String externalId;
@Column
private String url;
@Column
private ZonedDateTime postedAt;
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public Channel getChannel() {
return channel;
}
public void setChannel(Channel channel) {
this.channel = channel;
}
public ContentQueue getContent() {
return content;
}
public void setContent(ContentQueue content) {
this.content = content;
}
public String getExternalId() {
return externalId;
}
public void setExternalId(String externalId) {
this.externalId = externalId;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public ZonedDateTime getPostedAt() {
return postedAt;
}
public void setPostedAt(ZonedDateTime postedAt) {
this.postedAt = postedAt;
}
}
@@ -0,0 +1,7 @@
package kz.konturai.domain.enums;
public enum CampaignStatus {
PLANNED,
ACTIVE,
COMPLETED
}
@@ -0,0 +1,7 @@
package kz.konturai.domain.enums;
public enum ChannelType {
TELEGRAM,
VK,
INSTAGRAM
}
@@ -0,0 +1,9 @@
package kz.konturai.domain.enums;
public enum ContentStatus {
DRAFT,
PENDING_APPROVAL,
APPROVED,
PUBLISHED,
FAILED
}
@@ -0,0 +1,17 @@
package kz.konturai.dto;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import java.util.UUID;
import kz.konturai.domain.enums.CampaignStatus;
public class CampaignDto {
public UUID id;
public String name;
public String goal;
public BigDecimal budget;
public ZonedDateTime startAt;
public ZonedDateTime endAt;
public CampaignStatus status;
}
@@ -0,0 +1,12 @@
package kz.konturai.dto;
import java.util.UUID;
import kz.konturai.domain.enums.ChannelType;
public class ChannelDto {
public UUID id;
public String name;
public ChannelType type;
public boolean isActive;
}
@@ -0,0 +1,17 @@
package kz.konturai.dto;
import java.time.ZonedDateTime;
import java.util.UUID;
import kz.konturai.domain.enums.ContentStatus;
public class ContentQueueDto {
public UUID id;
public UUID campaignId;
public String locale;
public String topic;
public String postDraft;
public String assetsRefs;
public ZonedDateTime scheduledAt;
public int priority;
public ContentStatus status;
}
@@ -0,0 +1,23 @@
package kz.konturai.dto;
import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;
import java.time.ZonedDateTime;
import kz.konturai.domain.enums.CampaignStatus;
public class CreateCampaignRequest {
@NotBlank
public String name;
public String goal;
@PositiveOrZero
public BigDecimal budget;
@FutureOrPresent
public ZonedDateTime startAt;
@Future
public ZonedDateTime endAt;
public CampaignStatus status = CampaignStatus.PLANNED;
}
@@ -0,0 +1,16 @@
package kz.konturai.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import kz.konturai.domain.enums.ChannelType;
public class CreateChannelRequest {
@NotBlank
public String name;
@NotNull
public ChannelType type;
@NotBlank
public String apiKeyRef;
public boolean isActive = true;
}
@@ -0,0 +1,19 @@
package kz.konturai.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.ZonedDateTime;
import java.util.UUID;
public class CreateContentRequest {
@NotNull
public UUID campaignId;
@NotBlank
public String locale;
@NotBlank
public String topic;
public String postDraft;
public String assetsRefs;
public ZonedDateTime scheduledAt;
public Integer priority;
}
@@ -0,0 +1,13 @@
package kz.konturai.dto;
import java.time.ZonedDateTime;
import java.util.UUID;
public class MessageDto {
public UUID id;
public UUID channelId;
public UUID contentId;
public String externalId;
public String url;
public ZonedDateTime postedAt;
}
@@ -0,0 +1,7 @@
package kz.konturai.dto;
public class PostStatsDto {
public long views;
public long reactions;
public long shares;
}
@@ -0,0 +1,11 @@
package kz.konturai.exception;
public class SocialApiException extends RuntimeException {
public SocialApiException(String message) {
super(message);
}
public SocialApiException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.Campaign;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface CampaignRepository extends JpaRepository<Campaign, UUID> {
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.Channel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ChannelRepository extends JpaRepository<Channel, UUID> {
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.ContentQueue;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ContentQueueRepository extends JpaRepository<ContentQueue, UUID> {
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.KpiSnapshot;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface KpiSnapshotRepository extends JpaRepository<KpiSnapshot, UUID> {
}
@@ -0,0 +1,10 @@
package kz.konturai.repository;
import java.util.UUID;
import kz.konturai.domain.Message;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface MessageRepository extends JpaRepository<Message, UUID> {
}
@@ -0,0 +1,62 @@
package kz.konturai.service.impl;
import kz.konturai.dto.PostStatsDto;
import kz.konturai.exception.SocialApiException;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
@Component
public class TelegramApiClient {
public record TelegramPostResponse(String externalId, String url) {
}
private final RestTemplate restTemplate = new RestTemplate();
public TelegramPostResponse postMessage(String apiToken, String chatId, String text, String imagePath) {
try {
String url = "https://api.telegram.org/bot" + apiToken + "/sendMessage";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "{" +
"\"chat_id\":\"" + chatId + "\"," +
"\"text\":\"" + text.replace("\"", "\\\"") + "\"" +
"}";
HttpEntity<String> request = new HttpEntity<>(body, headers);
var response = restTemplate.postForEntity(url, request, java.util.Map.class);
Object msg = ((java.util.Map<?, ?>) response.getBody()).get("result");
String messageId = String.valueOf(((java.util.Map<?, ?>) msg).get("message_id"));
String externalUrl = "https://t.me/c/" + chatId + "/" + messageId;
return new TelegramPostResponse(messageId, externalUrl);
} catch (RestClientException e) {
throw new SocialApiException("Telegram API error: " + e.getMessage(), e);
}
}
public PostStatsDto getPostStatistics(String apiToken, String chatId, String externalId) {
try {
// Placeholder implementation; Telegram views require specific APIs and bot
// settings.
// Here we just call a harmless endpoint to validate token and return zeros.
String url = "https://api.telegram.org/bot" + apiToken + "/getChat";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = "{" +
"\"chat_id\":\"" + chatId + "\"" +
"}";
HttpEntity<String> request = new HttpEntity<>(body, headers);
restTemplate.postForEntity(url, request, java.util.Map.class);
PostStatsDto stats = new PostStatsDto();
stats.views = 0;
stats.reactions = 0;
stats.shares = 0;
return stats;
} catch (RestClientException e) {
throw new SocialApiException("Telegram API error: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,65 @@
package kz.konturai.service.spec;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import kz.konturai.domain.Channel;
import kz.konturai.domain.Message;
import kz.konturai.domain.KpiSnapshot;
import kz.konturai.dto.PostStatsDto;
import kz.konturai.repository.KpiSnapshotRepository;
import kz.konturai.repository.MessageRepository;
import kz.konturai.service.impl.TelegramApiClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AnalyticsService {
private static final Logger log = LoggerFactory.getLogger(AnalyticsService.class);
private final MessageRepository messageRepository;
private final KpiSnapshotRepository kpiSnapshotRepository;
private final TelegramApiClient telegramApiClient;
public AnalyticsService(MessageRepository messageRepository, KpiSnapshotRepository kpiSnapshotRepository,
TelegramApiClient telegramApiClient) {
this.messageRepository = messageRepository;
this.kpiSnapshotRepository = kpiSnapshotRepository;
this.telegramApiClient = telegramApiClient;
}
@Scheduled(cron = "0 0 * * * *")
@Transactional
public void collectAndSaveAnalytics() {
List<Message> messages = messageRepository.findAll();
for (Message m : messages) {
try {
Channel ch = m.getChannel();
// For demo we assume Telegram for all
PostStatsDto stats = telegramApiClient.getPostStatistics(ch.getApiKeyRef(), "" + 0, m.getExternalId());
saveSnapshot(m, ch, "VIEWS", BigDecimal.valueOf(stats.views));
saveSnapshot(m, ch, "REACTIONS", BigDecimal.valueOf(stats.reactions));
saveSnapshot(m, ch, "SHARES", BigDecimal.valueOf(stats.shares));
// Example ER calc: (reactions + shares) / max(views,1)
long denom = Math.max(stats.views, 1);
BigDecimal er = BigDecimal.valueOf((double) (stats.reactions + stats.shares) / denom);
saveSnapshot(m, ch, "ENGAGEMENT_RATE", er);
} catch (Exception ex) {
log.warn("Analytics collection failed for message {}: {}", m.getId(), ex.getMessage());
}
}
}
private void saveSnapshot(Message m, Channel ch, String metric, BigDecimal value) {
KpiSnapshot snap = new KpiSnapshot();
snap.setDate(LocalDate.now());
snap.setChannel(ch);
snap.setMetric(metric);
snap.setValue(value);
kpiSnapshotRepository.save(snap);
}
}
@@ -0,0 +1,78 @@
package kz.konturai.service.spec;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import kz.konturai.domain.Campaign;
import kz.konturai.dto.CampaignDto;
import kz.konturai.dto.CreateCampaignRequest;
import kz.konturai.repository.CampaignRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CampaignService {
private final CampaignRepository campaignRepository;
public CampaignService(CampaignRepository campaignRepository) {
this.campaignRepository = campaignRepository;
}
public List<CampaignDto> getAllCampaigns() {
return campaignRepository.findAll().stream().map(CampaignService::toDto).toList();
}
public CampaignDto getCampaignById(UUID id) {
Campaign c = campaignRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Campaign not found"));
return toDto(c);
}
@Transactional
public CampaignDto createCampaign(CreateCampaignRequest request) {
Campaign c = new Campaign();
c.setName(request.name);
c.setGoal(request.goal);
c.setBudget(request.budget);
c.setStartAt(request.startAt);
c.setEndAt(request.endAt);
c.setStatus(request.status);
Campaign saved = campaignRepository.save(c);
return toDto(saved);
}
@Transactional
public CampaignDto updateCampaign(UUID id, CreateCampaignRequest request) {
Campaign c = campaignRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Campaign not found"));
c.setName(request.name);
c.setGoal(request.goal);
c.setBudget(request.budget);
c.setStartAt(request.startAt);
c.setEndAt(request.endAt);
c.setStatus(request.status);
Campaign saved = campaignRepository.save(c);
return toDto(saved);
}
@Transactional
public void deleteCampaign(UUID id) {
if (!campaignRepository.existsById(id)) {
throw new NoSuchElementException("Campaign not found");
}
campaignRepository.deleteById(id);
}
private static CampaignDto toDto(Campaign c) {
CampaignDto dto = new CampaignDto();
dto.id = c.getId();
dto.name = c.getName();
dto.goal = c.getGoal();
dto.budget = c.getBudget();
dto.startAt = c.getStartAt();
dto.endAt = c.getEndAt();
dto.status = c.getStatus();
return dto;
}
}
@@ -0,0 +1,69 @@
package kz.konturai.service.spec;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import kz.konturai.domain.Channel;
import kz.konturai.dto.ChannelDto;
import kz.konturai.dto.CreateChannelRequest;
import kz.konturai.repository.ChannelRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ChannelService {
private final ChannelRepository channelRepository;
public ChannelService(ChannelRepository channelRepository) {
this.channelRepository = channelRepository;
}
public List<ChannelDto> getAllChannels() {
return channelRepository.findAll().stream().map(ChannelService::toDto).toList();
}
public ChannelDto getChannelById(UUID id) {
Channel ch = channelRepository.findById(id).orElseThrow(() -> new NoSuchElementException("Channel not found"));
return toDto(ch);
}
@Transactional
public ChannelDto createChannel(CreateChannelRequest request) {
Channel ch = new Channel();
ch.setName(request.name);
ch.setType(request.type);
ch.setApiKeyRef(request.apiKeyRef);
ch.setActive(request.isActive);
Channel saved = channelRepository.save(ch);
return toDto(saved);
}
@Transactional
public ChannelDto updateChannel(UUID id, CreateChannelRequest request) {
Channel ch = channelRepository.findById(id).orElseThrow(() -> new NoSuchElementException("Channel not found"));
ch.setName(request.name);
ch.setType(request.type);
ch.setApiKeyRef(request.apiKeyRef);
ch.setActive(request.isActive);
Channel saved = channelRepository.save(ch);
return toDto(saved);
}
@Transactional
public void deleteChannel(UUID id) {
if (!channelRepository.existsById(id)) {
throw new NoSuchElementException("Channel not found");
}
channelRepository.deleteById(id);
}
private static ChannelDto toDto(Channel ch) {
ChannelDto dto = new ChannelDto();
dto.id = ch.getId();
dto.name = ch.getName();
dto.type = ch.getType();
dto.isActive = ch.isActive();
return dto;
}
}
@@ -0,0 +1,131 @@
package kz.konturai.service.spec;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.UUID;
import kz.konturai.domain.Campaign;
import kz.konturai.domain.ContentQueue;
import kz.konturai.domain.Message;
import kz.konturai.domain.enums.ContentStatus;
import kz.konturai.dto.ContentQueueDto;
import kz.konturai.dto.CreateContentRequest;
import kz.konturai.dto.MessageDto;
import kz.konturai.repository.CampaignRepository;
import kz.konturai.repository.ContentQueueRepository;
import kz.konturai.repository.MessageRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ContentService {
private final ContentQueueRepository contentQueueRepository;
private final CampaignRepository campaignRepository;
private final MessageRepository messageRepository;
public ContentService(ContentQueueRepository contentQueueRepository, CampaignRepository campaignRepository,
MessageRepository messageRepository) {
this.contentQueueRepository = contentQueueRepository;
this.campaignRepository = campaignRepository;
this.messageRepository = messageRepository;
}
public List<ContentQueueDto> list() {
return contentQueueRepository.findAll().stream().map(ContentService::toDto).toList();
}
public ContentQueueDto get(UUID id) {
return contentQueueRepository.findById(id).map(ContentService::toDto)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
}
@Transactional
public ContentQueueDto create(CreateContentRequest r) {
Campaign campaign = campaignRepository.findById(r.campaignId)
.orElseThrow(() -> new NoSuchElementException("Campaign not found"));
ContentQueue c = new ContentQueue();
c.setCampaign(campaign);
c.setLocale(r.locale);
c.setTopic(r.topic);
c.setPostDraft(r.postDraft);
c.setAssetsRefs(r.assetsRefs);
c.setScheduledAt(r.scheduledAt);
c.setPriority(r.priority != null ? r.priority : 0);
c.setStatus(ContentStatus.DRAFT);
return toDto(contentQueueRepository.save(c));
}
@Transactional
public ContentQueueDto update(UUID id, CreateContentRequest r) {
ContentQueue c = contentQueueRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
if (r.campaignId != null) {
Campaign campaign = campaignRepository.findById(r.campaignId)
.orElseThrow(() -> new NoSuchElementException("Campaign not found"));
c.setCampaign(campaign);
}
if (r.locale != null)
c.setLocale(r.locale);
if (r.topic != null)
c.setTopic(r.topic);
if (r.postDraft != null)
c.setPostDraft(r.postDraft);
if (r.assetsRefs != null)
c.setAssetsRefs(r.assetsRefs);
if (r.scheduledAt != null)
c.setScheduledAt(r.scheduledAt);
if (r.priority != null)
c.setPriority(r.priority);
return toDto(contentQueueRepository.save(c));
}
@Transactional
public void delete(UUID id) {
if (!contentQueueRepository.existsById(id)) {
throw new NoSuchElementException("Content not found");
}
contentQueueRepository.deleteById(id);
}
@Transactional
public ContentQueueDto approve(UUID id) {
ContentQueue c = contentQueueRepository.findById(id)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
c.setStatus(ContentStatus.APPROVED);
return toDto(contentQueueRepository.save(c));
}
public List<MessageDto> listMessages(UUID contentId) {
ContentQueue c = contentQueueRepository.findById(contentId)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
return messageRepository.findAll().stream()
.filter(m -> m.getContent() != null && m.getContent().getId().equals(c.getId()))
.map(ContentService::toDto)
.toList();
}
private static ContentQueueDto toDto(ContentQueue c) {
ContentQueueDto dto = new ContentQueueDto();
dto.id = c.getId();
dto.campaignId = c.getCampaign() != null ? c.getCampaign().getId() : null;
dto.locale = c.getLocale();
dto.topic = c.getTopic();
dto.postDraft = c.getPostDraft();
dto.assetsRefs = c.getAssetsRefs();
dto.scheduledAt = c.getScheduledAt();
dto.priority = c.getPriority();
dto.status = c.getStatus();
return dto;
}
private static MessageDto toDto(Message m) {
MessageDto dto = new MessageDto();
dto.id = m.getId();
dto.channelId = m.getChannel() != null ? m.getChannel().getId() : null;
dto.contentId = m.getContent() != null ? m.getContent().getId() : null;
dto.externalId = m.getExternalId();
dto.url = m.getUrl();
dto.postedAt = m.getPostedAt();
return dto;
}
}
@@ -0,0 +1,85 @@
package kz.konturai.service.spec;
import java.time.ZonedDateTime;
import java.util.NoSuchElementException;
import java.util.UUID;
import kz.konturai.domain.Channel;
import kz.konturai.domain.ContentQueue;
import kz.konturai.domain.Message;
import kz.konturai.domain.enums.ContentStatus;
import kz.konturai.repository.ChannelRepository;
import kz.konturai.repository.ContentQueueRepository;
import kz.konturai.repository.MessageRepository;
import kz.konturai.service.impl.TelegramApiClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PublishingService {
private static final Logger log = LoggerFactory.getLogger(PublishingService.class);
private final ContentQueueRepository contentQueueRepository;
private final MessageRepository messageRepository;
private final ChannelRepository channelRepository;
private final TelegramApiClient telegramApiClient;
public PublishingService(ContentQueueRepository contentQueueRepository, MessageRepository messageRepository,
ChannelRepository channelRepository, TelegramApiClient telegramApiClient) {
this.contentQueueRepository = contentQueueRepository;
this.messageRepository = messageRepository;
this.channelRepository = channelRepository;
this.telegramApiClient = telegramApiClient;
}
@Scheduled(cron = "0 * * * * *")
@Transactional
public void findAndPublishScheduledContent() {
// naive scan (should use a query in repo for production)
contentQueueRepository.findAll().stream()
.filter(c -> c.getStatus() == ContentStatus.APPROVED)
.filter(c -> c.getScheduledAt() != null && !c.getScheduledAt().isAfter(ZonedDateTime.now()))
.forEach(c -> {
try {
publishSingle(c);
} catch (Exception ex) {
log.error("Failed to publish content {}: {}", c.getId(), ex.getMessage());
c.setStatus(ContentStatus.FAILED);
}
});
}
@Transactional
public Message publishContentById(UUID contentId) {
ContentQueue c = contentQueueRepository.findById(contentId)
.orElseThrow(() -> new NoSuchElementException("Content not found"));
return publishSingle(c);
}
private Message publishSingle(ContentQueue c) {
// For demo, assume Telegram channel and use campaign id as channel id
// placeholder
// In real design, ContentQueue should reference a Channel. Here we pick by
// priority mod.
Channel channel = channelRepository.findAll().stream().findFirst()
.orElseThrow(() -> new NoSuchElementException("No channels configured"));
String text = c.getPostDraft() != null ? c.getPostDraft() : "";
TelegramApiClient.TelegramPostResponse resp = telegramApiClient.postMessage(channel.getApiKeyRef(), "" + 0,
text,
null);
Message m = new Message();
m.setChannel(channel);
m.setContent(c);
m.setExternalId(resp.externalId());
m.setUrl(resp.url());
m.setPostedAt(ZonedDateTime.now());
Message saved = messageRepository.save(m);
c.setStatus(ContentStatus.PUBLISHED);
return saved;
}
}
@@ -0,0 +1,60 @@
-- Create SMM core tables
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- channels
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
type VARCHAR(32) NOT NULL,
api_key_ref VARCHAR(512),
is_active BOOLEAN NOT NULL
);
-- campaigns
CREATE TABLE IF NOT EXISTS campaigns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
goal TEXT,
budget NUMERIC(19,2),
start_at TIMESTAMPTZ,
end_at TIMESTAMPTZ,
status VARCHAR(32) NOT NULL
);
-- content_queue
CREATE TABLE IF NOT EXISTS content_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id UUID REFERENCES campaigns(id) ON DELETE SET NULL,
locale VARCHAR(16),
topic TEXT,
post_draft JSONB,
assets_refs JSONB,
scheduled_at TIMESTAMPTZ,
priority INT,
status VARCHAR(32) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_content_queue_campaign ON content_queue(campaign_id);
-- messages
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
content_id UUID REFERENCES content_queue(id) ON DELETE SET NULL,
external_id VARCHAR(255),
url TEXT,
posted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id);
CREATE INDEX IF NOT EXISTS idx_messages_content ON messages(content_id);
-- kpi_snapshots
CREATE TABLE IF NOT EXISTS kpi_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
date DATE NOT NULL,
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
metric VARCHAR(64) NOT NULL,
value NUMERIC(19,4) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_kpi_snapshots_channel_date ON kpi_snapshots(channel_id, date);