diff --git a/README.md b/README.md
index 09036b26..71b85ab4 100644
--- a/README.md
+++ b/README.md
@@ -17,15 +17,24 @@
[](https://spring.io/projects/spring-boot)
[](https://www.postgresql.org/)
[](https://redis.io/)
+[](https://kafka.apache.org/)
[](https://www.liquibase.com/)
+[](https://docs.spring.io/spring-framework/reference/web/websocket.html)
[](https://react.dev/)
[](https://www.typescriptlang.org/)
[](https://tailwindcss.com/)
+[](https://tanstack.com/query)
+[](https://zod.dev/)
+[](docs/backend/decisions/README.md#adr-002-multi-provider-ai-fallback-router-architecture)
+[](https://stripe.com/)
+[](https://playwright.dev/)
+[](https://k6.io/)
[](https://www.docker.com/)
[](https://nginx.org/)
[](https://testcontainers.com/)
[](https://core.telegram.org/bots/api)
+[](https://www.cloudflare.com/products/turnstile/)
---
@@ -39,8 +48,6 @@ It serves as a full-scale technical showcase of building resilient, high-concurr
----
-
## Tech Stack
### Backend — `backend/`
@@ -53,6 +60,7 @@ It serves as a full-scale technical showcase of building resilient, high-concurr
- **Transactional Outbox & DLQ**: Resilient asynchronous event delivery with retry backoff and Dead Letter Queue
- **Distributed Idempotency Layer**: Redis-backed replay protection on all critical mutation endpoints
- **Dynamic Tier-Based Rate Limiter**: Token-bucket algorithm with RFC headers and role-based quotas
+- **Cloudflare Turnstile Verify API**: Server-side bot verification and credential stuffing protection on auth endpoints
- **Enterprise Security Headers**: Strict HSTS, Content-Type Options, Frame Options, Referrer and Permissions policies
- **GDPR & SOC2 PII Masking**: Automatic redacting of emails, phone numbers, and secrets in runtime logs
- **JPA Deep Entity Graphs & JDBC Batching**: High-throughput statement batching (size=50) and N+1 query elimination
@@ -71,6 +79,7 @@ It serves as a full-scale technical showcase of building resilient, high-concurr
- **TanStack Query v5**: Server state caching, background synchronization, and optimistic mutations
- **Zustand v5**: Client-only synchronous state (auth session, canvas selection, network health)
- **React Hook Form + Zod v4**: Strict type-safe form validation and DTO transformations
+- **Cloudflare Turnstile Widget**: Non-blocking bot protection with dynamic theme and flexible sizing
- **React Router v7**: Nested dashboard layouts and role-based route guards
- **STOMP & SockJS Client**: Real-time live chat subscriptions
- **TanStack Virtual & Custom Virtualizer**: Scalable windowing for high-volume conversation and contact lists
@@ -90,6 +99,7 @@ It serves as a full-scale technical showcase of building resilient, high-concurr
- **Visual Bot Constructor**: Drag-and-drop conversational graph builder supporting Message, Menu, Action, Condition, and AI nodes.
- **Omnichannel CRM & Live Inbox**: Two-way Telegram chat with live agent intervention, conversation search, and lead status pipelines.
+- **Cloudflare Turnstile Bot & Spam Protection**: Smart non-interactive challenge verification securing authentication flows (registration and login) with zero friction.
- **Transactional Outbox & Integrations**: Guaranteed at-least-once delivery for webhooks (Hotmart, e-commerce, CRM leads) with automatic retry backoff and DLQ tracking.
- **Distributed Idempotency Protection**: Safe concurrent execution prevention for AI generation, campaign dispatches, team invites, and support appeals.
- **Dynamic Tier Rate Limiting**: Multi-tenant token-bucket rate limiting per plan tier (`ROLE_OWNER`, `ROLE_PRO`, `ROLE_ENTERPRISE`, `ROLE_ADMIN`).
diff --git a/backend/pom.xml b/backend/pom.xml
index cebbff92..59732ea2 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -30,7 +30,7 @@
21
1.6.3
0.12.6
- 1.20.4
+ 1.20.6
polchduikt_launchly
polchduikt
https://sonarcloud.io
@@ -101,6 +101,10 @@
org.springframework.boot
spring-boot-starter-jackson
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
org.springframework.boot
spring-boot-starter-websocket
@@ -273,6 +277,10 @@
${testcontainers.version}
test
+
+ com.github.ben-manes.caffeine
+ caffeine
+
@@ -297,6 +305,7 @@
org.projectlombok
lombok
+ 1.18.36
org.mapstruct
diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java b/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java
index e798053b..b9d17e39 100644
--- a/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java
+++ b/backend/src/main/java/com/launchly/admin/controller/AdminAutomationController.java
@@ -12,6 +12,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
@@ -82,7 +83,7 @@ public ResponseEntity toggleAutomation(@Parameter(description = "Automatio
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity blockAutomation(
@Parameter(description = "Automation ID") @PathVariable Long automationId,
- @RequestBody(required = false) AdminBlockRequest request) {
+ @Valid @RequestBody(required = false) AdminBlockRequest request) {
adminAutomationService.blockAutomation(automationId, request);
return ResponseEntity.ok().build();
}
diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java b/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java
index 8e1b649a..2dfe0b15 100644
--- a/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java
+++ b/backend/src/main/java/com/launchly/admin/controller/AdminBroadcastController.java
@@ -12,6 +12,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
@@ -82,7 +83,7 @@ public ResponseEntity cancelBroadcast(@Parameter(description = "Broadcast
@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
public ResponseEntity blockBroadcast(
@Parameter(description = "Broadcast ID") @PathVariable Long broadcastId,
- @RequestBody(required = false) AdminBlockRequest request) {
+ @Valid @RequestBody(required = false) AdminBlockRequest request) {
adminBroadcastService.blockBroadcast(broadcastId, request);
return ResponseEntity.ok().build();
}
diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminOutboxController.java b/backend/src/main/java/com/launchly/admin/controller/AdminOutboxController.java
index 969cb48f..2ee2d281 100644
--- a/backend/src/main/java/com/launchly/admin/controller/AdminOutboxController.java
+++ b/backend/src/main/java/com/launchly/admin/controller/AdminOutboxController.java
@@ -16,7 +16,6 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
-
import java.util.Map;
@Tag(name = "Admin: Outbox & DLQ", description = "Dead letter queue inspection and event replay management")
diff --git a/backend/src/main/java/com/launchly/admin/controller/AdminSupportChatController.java b/backend/src/main/java/com/launchly/admin/controller/AdminSupportChatController.java
index 32081577..4d2ad3e5 100644
--- a/backend/src/main/java/com/launchly/admin/controller/AdminSupportChatController.java
+++ b/backend/src/main/java/com/launchly/admin/controller/AdminSupportChatController.java
@@ -1,8 +1,9 @@
package com.launchly.admin.controller;
-import com.launchly.admin.dto.CreateMessageRequest;
-import com.launchly.admin.dto.SupportMessageDto;
-import com.launchly.admin.dto.SupportTicketDto;
+import com.launchly.support.dto.CreateMessageRequest;
+import com.launchly.support.dto.SupportMessageDto;
+import com.launchly.support.dto.SupportTicketDto;
+import com.launchly.support.enums.TicketStatus;
import com.launchly.admin.service.AdminSupportChatService;
import com.launchly.common.exception.ErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
@@ -12,6 +13,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
@@ -66,7 +68,7 @@ public ResponseEntity getTicketDetail(@Parameter(description =
@PostMapping("/{id}/messages")
public ResponseEntity addMessage(
@Parameter(description = "Ticket ID") @PathVariable Long id,
- @RequestBody CreateMessageRequest request,
+ @Valid @RequestBody CreateMessageRequest request,
@AuthenticationPrincipal UserDetails userDetails) {
return ResponseEntity.ok(adminSupportChatService.addMessage(id, request.getText(), userDetails.getUsername()));
}
@@ -91,7 +93,7 @@ public ResponseEntity toggleFavorite(@Parameter(description =
@PatchMapping("/{id}/status")
public ResponseEntity updateStatus(
@Parameter(description = "Ticket ID") @PathVariable Long id,
- @Parameter(description = "New status: OPEN, PENDING, CLOSED") @RequestParam(required = false) String status) {
+ @Parameter(description = "New status: OPEN, PENDING, CLOSED") @RequestParam(required = false) TicketStatus status) {
return ResponseEntity.ok(adminSupportChatService.updateStatus(id, status));
}
diff --git a/backend/src/main/java/com/launchly/admin/entity/UserAuditLog.java b/backend/src/main/java/com/launchly/admin/entity/UserAuditLog.java
index 36fb2e05..ac2bf329 100644
--- a/backend/src/main/java/com/launchly/admin/entity/UserAuditLog.java
+++ b/backend/src/main/java/com/launchly/admin/entity/UserAuditLog.java
@@ -11,7 +11,10 @@
@Index(name = "idx_user_audit_logs_user_date", columnList = "user_id, created_at"),
@Index(name = "idx_audit_logs_user_created", columnList = "user_id, created_at DESC")
})
-@Data
+@Getter
+@Setter
+@ToString(exclude = {"user"})
+@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
@@ -19,6 +22,7 @@ public class UserAuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
+ @EqualsAndHashCode.Include
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
diff --git a/backend/src/main/java/com/launchly/admin/mapper/AdminMapper.java b/backend/src/main/java/com/launchly/admin/mapper/AdminMapper.java
index ec2167ca..4f26dfbf 100644
--- a/backend/src/main/java/com/launchly/admin/mapper/AdminMapper.java
+++ b/backend/src/main/java/com/launchly/admin/mapper/AdminMapper.java
@@ -1,12 +1,25 @@
package com.launchly.admin.mapper;
+import com.launchly.admin.dto.AdminAutomationDto;
+import com.launchly.admin.dto.AdminBroadcastDetailDto;
import com.launchly.admin.dto.AdminBroadcastDto;
+import com.launchly.admin.dto.AdminUserDetailDto;
import com.launchly.admin.dto.AdminUserDto;
+import com.launchly.admin.dto.UserActivityDto;
+import com.launchly.admin.dto.UserAutomationSummaryDto;
+import com.launchly.admin.dto.UserBroadcastSummaryDto;
+import com.launchly.admin.entity.UserAuditLog;
import com.launchly.auth.entity.User;
+import com.launchly.bot.entity.Bot;
+import com.launchly.bot.entity.FlowSchema;
import com.launchly.broadcast.entity.BroadcastCampaign;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.ReportingPolicy;
+import org.springframework.data.domain.Page;
+
+import java.time.LocalDateTime;
+import java.util.List;
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface AdminMapper {
@@ -44,7 +57,7 @@ public interface AdminMapper {
@Mapping(target = "blockedAt", source = "campaign.blockedAt")
@Mapping(target = "createdByEmail", expression = "java(creator != null && creator.getEmail() != null ? creator.getEmail() : defaultAuthor)")
@Mapping(target = "authorName", expression = "java(creator != null && creator.getName() != null ? creator.getName() : defaultAuthor)")
- @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt() : java.time.LocalDateTime.now())")
+ @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt() : LocalDateTime.now())")
AdminBroadcastDto toBroadcastDto(BroadcastCampaign campaign, User creator, String defaultAuthor);
@Mapping(target = "id", source = "campaign.id")
@@ -55,17 +68,16 @@ public interface AdminMapper {
@Mapping(target = "sentCount", expression = "java(campaign.getSentCount() != null ? campaign.getSentCount() : 0)")
@Mapping(target = "failedCount", expression = "java(campaign.getFailedCount() != null ? campaign.getFailedCount() : 0)")
@Mapping(target = "totalCount", expression = "java(campaign.getTotalCount() != null ? campaign.getTotalCount() : 0)")
- @Mapping(target = "status", expression = "java(campaign.isBlocked() ? \"BLOCKED\" : (campaign.getStatus() != null ? campaign.getStatus().name() : \"DRAFT\"))")
- @Mapping(target = "blocked", source = "campaign.blocked")
+ @Mapping(target = "status", expression = "java(campaign.getStatus() != null ? campaign.getStatus().name() : \"DRAFT\")")
@Mapping(target = "blockReason", source = "campaign.blockReason")
@Mapping(target = "blockedAt", source = "campaign.blockedAt")
@Mapping(target = "createdByEmail", expression = "java(creator != null && creator.getEmail() != null ? creator.getEmail() : defaultAuthor)")
@Mapping(target = "authorName", expression = "java(creator != null && creator.getName() != null ? creator.getName() : defaultAuthor)")
@Mapping(target = "authorId", expression = "java(creator != null ? creator.getId() : null)")
- @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt() : java.time.LocalDateTime.now())")
+ @Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt() : LocalDateTime.now())")
@Mapping(target = "scheduledAt", source = "campaign.scheduledAt")
@Mapping(target = "activities", source = "activities")
- com.launchly.admin.dto.AdminBroadcastDetailDto toBroadcastDetailDto(BroadcastCampaign campaign, User creator, String defaultAuthor, org.springframework.data.domain.Page activities);
+ AdminBroadcastDetailDto toBroadcastDetailDto(BroadcastCampaign campaign, User creator, String defaultAuthor, Page activities);
@Mapping(target = "id", source = "flow.id")
@Mapping(target = "name", expression = "java(bot != null && bot.getName() != null ? bot.getName() : \"Flow #\" + flow.getId())")
@@ -79,8 +91,8 @@ public interface AdminMapper {
@Mapping(target = "blockedAt", expression = "java(bot != null ? bot.getBlockedAt() : null)")
@Mapping(target = "triggerCount", source = "runsCount")
@Mapping(target = "errorCount", constant = "0L")
- @Mapping(target = "lastExecutedAt", expression = "java(flow.getUpdatedAt() != null ? flow.getUpdatedAt() : java.time.LocalDateTime.now())")
- com.launchly.admin.dto.AdminAutomationDto toAutomationDto(com.launchly.bot.entity.FlowSchema flow, com.launchly.bot.entity.Bot bot, User owner, String resolvedBotName, boolean isConnected, int runsCount);
+ @Mapping(target = "lastExecutedAt", expression = "java(flow.getUpdatedAt() != null ? flow.getUpdatedAt() : LocalDateTime.now())")
+ AdminAutomationDto toAutomationDto(FlowSchema flow, Bot bot, User owner, String resolvedBotName, boolean isConnected, int runsCount);
@Mapping(target = "id", source = "log.id")
@Mapping(target = "targetId", source = "log.targetId")
@@ -90,7 +102,7 @@ public interface AdminMapper {
@Mapping(target = "category", source = "log.category")
@Mapping(target = "badge", source = "log.badge")
@Mapping(target = "timestamp", source = "log.createdAt")
- com.launchly.admin.dto.UserActivityDto toActivityDto(com.launchly.admin.entity.UserAuditLog log);
+ UserActivityDto toActivityDto(UserAuditLog log);
@Mapping(target = "id", source = "flow.id")
@Mapping(target = "name", expression = "java(flow.getBot() != null ? flow.getBot().getName() : \"Flow #\" + flow.getId())")
@@ -98,7 +110,7 @@ public interface AdminMapper {
@Mapping(target = "active", expression = "java(flow.getBot() != null && flow.getBot().isActive() && isConnected)")
@Mapping(target = "triggerCount", source = "runs")
@Mapping(target = "triggerType", constant = "KEYWORD")
- com.launchly.admin.dto.UserAutomationSummaryDto toAutomationSummaryDto(com.launchly.bot.entity.FlowSchema flow, String resolvedBotName, boolean isConnected, int runs);
+ UserAutomationSummaryDto toAutomationSummaryDto(FlowSchema flow, String resolvedBotName, boolean isConnected, int runs);
@Mapping(target = "id", source = "campaign.id")
@Mapping(target = "name", source = "campaign.name")
@@ -106,7 +118,7 @@ public interface AdminMapper {
@Mapping(target = "status", expression = "java(campaign.getStatus() != null ? campaign.getStatus().name() : \"DRAFT\")")
@Mapping(target = "sentCount", expression = "java(campaign.getSentCount() != null ? campaign.getSentCount() : 0)")
@Mapping(target = "createdAt", expression = "java(campaign.getCreatedAt() != null ? campaign.getCreatedAt().toString() : \"\")")
- com.launchly.admin.dto.UserBroadcastSummaryDto toBroadcastSummaryDto(BroadcastCampaign campaign);
+ UserBroadcastSummaryDto toBroadcastSummaryDto(BroadcastCampaign campaign);
@Mapping(target = "id", source = "user.id")
@Mapping(target = "email", source = "user.email")
@@ -130,7 +142,5 @@ public interface AdminMapper {
@Mapping(target = "activities", source = "activities")
@Mapping(target = "automations", source = "automations")
@Mapping(target = "broadcasts", source = "broadcasts")
- com.launchly.admin.dto.AdminUserDetailDto toUserDetailDto(User user, long botsCount, long automationsCount, long broadcastsCount, long contactsCount, String planName, java.time.LocalDateTime lastActivity, org.springframework.data.domain.Page activities, java.util.List automations, java.util.List broadcasts);
+ AdminUserDetailDto toUserDetailDto(User user, long botsCount, long automationsCount, long broadcastsCount, long contactsCount, String planName, LocalDateTime lastActivity, Page activities, List automations, List broadcasts);
}
-
-
diff --git a/backend/src/main/java/com/launchly/admin/service/AdminSupportChatService.java b/backend/src/main/java/com/launchly/admin/service/AdminSupportChatService.java
index 546f0909..0325155f 100644
--- a/backend/src/main/java/com/launchly/admin/service/AdminSupportChatService.java
+++ b/backend/src/main/java/com/launchly/admin/service/AdminSupportChatService.java
@@ -1,7 +1,8 @@
package com.launchly.admin.service;
-import com.launchly.admin.dto.SupportMessageDto;
-import com.launchly.admin.dto.SupportTicketDto;
+import com.launchly.support.dto.SupportMessageDto;
+import com.launchly.support.dto.SupportTicketDto;
+import com.launchly.support.enums.TicketStatus;
import org.springframework.data.domain.Page;
public interface AdminSupportChatService {
@@ -9,6 +10,6 @@ public interface AdminSupportChatService {
SupportTicketDto getSupportTicketDetail(Long id);
SupportMessageDto addMessage(Long ticketId, String text, String managerEmail);
SupportTicketDto toggleFavorite(Long id);
- SupportTicketDto updateStatus(Long id, String status);
+ SupportTicketDto updateStatus(Long id, TicketStatus status);
SupportTicketDto claimTicket(Long id, String managerEmail);
}
diff --git a/backend/src/main/java/com/launchly/admin/service/impl/AdminAutomationServiceImpl.java b/backend/src/main/java/com/launchly/admin/service/impl/AdminAutomationServiceImpl.java
index a6e95d9d..6dc08dd7 100644
--- a/backend/src/main/java/com/launchly/admin/service/impl/AdminAutomationServiceImpl.java
+++ b/backend/src/main/java/com/launchly/admin/service/impl/AdminAutomationServiceImpl.java
@@ -48,7 +48,7 @@ public class AdminAutomationServiceImpl implements AdminAutomationService {
private final AdminPeriodResolver periodResolver;
private final AdminMapper adminMapper;
private final MessageUtils messageUtils;
- private final ObjectMapper objectMapper = new ObjectMapper();
+ private final ObjectMapper objectMapper;
@Override
@Transactional(readOnly = true)
diff --git a/backend/src/main/java/com/launchly/admin/service/impl/AdminStatsServiceImpl.java b/backend/src/main/java/com/launchly/admin/service/impl/AdminStatsServiceImpl.java
index 64a2dc86..cf515866 100644
--- a/backend/src/main/java/com/launchly/admin/service/impl/AdminStatsServiceImpl.java
+++ b/backend/src/main/java/com/launchly/admin/service/impl/AdminStatsServiceImpl.java
@@ -24,11 +24,13 @@
import com.launchly.billing.repository.SubscriptionRepository;
import com.launchly.common.utils.DateTimeUtils;
import com.launchly.common.utils.MathUtils;
-import com.launchly.integration.entity.Integration;
import com.launchly.integration.entity.IntegrationType;
import com.launchly.integration.repository.IntegrationRepository;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
@@ -39,6 +41,7 @@
import java.util.*;
import java.util.stream.Collectors;
+@Slf4j
@Service
@RequiredArgsConstructor
public class AdminStatsServiceImpl implements AdminStatsService {
@@ -72,93 +75,125 @@ public class AdminStatsServiceImpl implements AdminStatsService {
public AdminStatsDto getStats(String search, String period, LocalDateTime startDate, LocalDateTime endDate) {
LocalDateTime resolvedEnd = endDate != null ? endDate : LocalDateTime.now();
LocalDateTime resolvedStart = periodResolver.resolve(period, startDate);
+ LocalDateTime previousStart = resolvedStart.minus(Duration.between(resolvedStart.atZone(ZoneOffset.UTC), resolvedEnd.atZone(ZoneOffset.UTC)));
- List allUsers = userQueryService.findAllUsers();
- List allBots = botRepository.findAll();
- List allBotUsers = botUserRepository.findAll();
- List allSchemas = flowSchemaRepository.findAll();
- List allBroadcasts = broadcastCampaignRepository.findAll();
+ long totalUsers;
+ long totalOwners;
+ long activeOwners;
+ long totalBotUsers;
+ long activeBots;
+ long totalAutomations;
+ long totalMessagesSent;
+ long activeManagers;
+
+ long prevOwners;
+ long prevBotUsersCount;
+ long prevActiveBots;
+ long prevAutomations;
+ long prevMessages;
+
+ List rangeUsers;
+ List rangeBots;
+ List rangeBotUsers;
+ List rangeSchemas;
+ List rangeBroadcasts;
+
+ long totalOwnersCount;
+ boolean hasBots;
if (search != null && !search.trim().isEmpty()) {
String q = search.trim().toLowerCase();
- allUsers = allUsers.stream()
+ List allUsers = userQueryService.findAllUsers().stream()
.filter(u -> AdminStatsCalculator.contains(u.getName(), q) || AdminStatsCalculator.contains(u.getEmail(), q) || AdminStatsCalculator.contains(u.getTelegramUsername(), q))
.collect(Collectors.toList());
- allBots = allBots.stream()
+ List allBots = botRepository.findAll().stream()
.filter(b -> AdminStatsCalculator.contains(b.getName(), q) || AdminStatsCalculator.contains(b.getUsername(), q))
.collect(Collectors.toList());
- allSchemas = allSchemas.stream()
+ List allBotUsers = botUserRepository.findAll();
+ List allSchemas = flowSchemaRepository.findAll().stream()
.filter(s -> s.getBot() != null && AdminStatsCalculator.contains(s.getBot().getName(), q))
.collect(Collectors.toList());
- allBroadcasts = allBroadcasts.stream()
+ List allBroadcasts = broadcastCampaignRepository.findAll().stream()
.filter(bc -> AdminStatsCalculator.contains(bc.getName(), q))
.collect(Collectors.toList());
- }
-
- final LocalDateTime finalStart = resolvedStart;
- final LocalDateTime finalEnd = resolvedEnd;
-
- List rangeUsers = DateTimeUtils.filterByDateRange(allUsers, u -> u.getCreatedAt(), finalStart, finalEnd);
- List rangeBots = DateTimeUtils.filterByDateRange(allBots, b -> b.getCreatedAt(), finalStart, finalEnd);
- List rangeBotUsers = DateTimeUtils.filterByDateRange(allBotUsers, bu -> bu.getCreatedAt(), finalStart, finalEnd);
- List rangeSchemas = DateTimeUtils.filterByDateRange(allSchemas, s -> s.getCreatedAt(), finalStart, finalEnd);
- List rangeBroadcasts = DateTimeUtils.filterByDateRange(allBroadcasts, bc -> bc.getCreatedAt(), finalStart, finalEnd);
-
- long totalUsers = rangeUsers.size();
- long totalOwners = rangeUsers.stream()
- .filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN)
- .count();
-
- LocalDateTime fifteenMinsAgo = LocalDateTime.now().minusMinutes(15);
- long activeOwners = allUsers.stream()
- .filter(u -> u.isActive() && u.getUpdatedAt() != null && u.getUpdatedAt().isAfter(fifteenMinsAgo))
- .count();
- long totalBotUsers = rangeBotUsers.size();
- long activeBots = rangeBots.stream().filter(Bot::isActive).count();
- long totalAutomations = rangeSchemas.size();
-
- long totalMessagesSent = rangeBroadcasts.stream()
- .mapToLong(c -> c.getSentCount() != null ? c.getSentCount() : 0)
- .sum();
-
- long activeManagers = rangeUsers.stream()
- .filter(u -> u.getRole() == Role.ROLE_MANAGER)
- .count();
+ rangeUsers = DateTimeUtils.filterByDateRange(allUsers, User::getCreatedAt, resolvedStart, resolvedEnd);
+ rangeBots = DateTimeUtils.filterByDateRange(allBots, Bot::getCreatedAt, resolvedStart, resolvedEnd);
+ rangeBotUsers = DateTimeUtils.filterByDateRange(allBotUsers, BotUser::getCreatedAt, resolvedStart, resolvedEnd);
+ rangeSchemas = DateTimeUtils.filterByDateRange(allSchemas, FlowSchema::getCreatedAt, resolvedStart, resolvedEnd);
+ rangeBroadcasts = DateTimeUtils.filterByDateRange(allBroadcasts, BroadcastCampaign::getCreatedAt, resolvedStart, resolvedEnd);
+
+ totalUsers = rangeUsers.size();
+ totalOwners = rangeUsers.stream().filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN).count();
+ LocalDateTime fifteenMinsAgo = LocalDateTime.now().minusMinutes(15);
+ activeOwners = allUsers.stream().filter(u -> u.isActive() && u.getUpdatedAt() != null && u.getUpdatedAt().isAfter(fifteenMinsAgo)).count();
+ totalBotUsers = rangeBotUsers.size();
+ activeBots = rangeBots.stream().filter(Bot::isActive).count();
+ totalAutomations = rangeSchemas.size();
+ totalMessagesSent = rangeBroadcasts.stream().mapToLong(c -> c.getSentCount() != null ? c.getSentCount() : 0).sum();
+ activeManagers = rangeUsers.stream().filter(u -> u.getRole() == Role.ROLE_MANAGER).count();
+
+ List prevUsers = DateTimeUtils.filterByDateRange(allUsers, User::getCreatedAt, previousStart, resolvedStart);
+ List prevBotUsers = DateTimeUtils.filterByDateRange(allBotUsers, BotUser::getCreatedAt, previousStart, resolvedStart);
+ List prevBots = DateTimeUtils.filterByDateRange(allBots, Bot::getCreatedAt, previousStart, resolvedStart);
+ List prevSchemas = DateTimeUtils.filterByDateRange(allSchemas, FlowSchema::getCreatedAt, previousStart, resolvedStart);
+ List prevBroadcasts = DateTimeUtils.filterByDateRange(allBroadcasts, BroadcastCampaign::getCreatedAt, previousStart, resolvedStart);
+
+ prevOwners = prevUsers.stream().filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN).count();
+ prevBotUsersCount = prevBotUsers.size();
+ prevActiveBots = prevBots.stream().filter(Bot::isActive).count();
+ prevAutomations = prevSchemas.size();
+ prevMessages = prevBroadcasts.stream().mapToLong(c -> c.getSentCount() != null ? c.getSentCount() : 0).sum();
+
+ totalOwnersCount = allUsers.stream().filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN).count();
+ hasBots = !allBots.isEmpty();
+ } else {
+ totalUsers = userQueryService.countByCreatedAtBetween(resolvedStart, resolvedEnd);
+ totalOwners = userQueryService.countByRoleInAndCreatedAtBetween(List.of(Role.ROLE_OWNER, Role.ROLE_ADMIN), resolvedStart, resolvedEnd);
+ LocalDateTime fifteenMinsAgo = LocalDateTime.now().minusMinutes(15);
+ activeOwners = userQueryService.countByActiveTrueAndUpdatedAtAfter(fifteenMinsAgo);
+ totalBotUsers = botUserRepository.countByCreatedAtBetween(resolvedStart, resolvedEnd);
+ activeBots = botRepository.countByActiveTrueAndCreatedAtBetween(resolvedStart, resolvedEnd);
+ totalAutomations = flowSchemaRepository.countByCreatedAtBetween(resolvedStart, resolvedEnd);
+ totalMessagesSent = broadcastCampaignRepository.sumSentCountByCreatedAtBetween(resolvedStart, resolvedEnd);
+ activeManagers = userQueryService.countByRoleAndCreatedAtBetween(Role.ROLE_MANAGER, resolvedStart, resolvedEnd);
+
+ prevOwners = userQueryService.countByRoleInAndCreatedAtBetween(List.of(Role.ROLE_OWNER, Role.ROLE_ADMIN), previousStart, resolvedStart);
+ prevBotUsersCount = botUserRepository.countByCreatedAtBetween(previousStart, resolvedStart);
+ prevActiveBots = botRepository.countByActiveTrueAndCreatedAtBetween(previousStart, resolvedStart);
+ prevAutomations = flowSchemaRepository.countByCreatedAtBetween(previousStart, resolvedStart);
+ prevMessages = broadcastCampaignRepository.sumSentCountByCreatedAtBetween(previousStart, resolvedStart);
+
+ rangeUsers = userQueryService.findByCreatedAtBetween(resolvedStart, resolvedEnd);
+ rangeBots = botRepository.findByCreatedAtBetween(resolvedStart, resolvedEnd);
+ rangeBotUsers = botUserRepository.findByCreatedAtBetween(resolvedStart, resolvedEnd);
+ rangeSchemas = flowSchemaRepository.findByCreatedAtBetween(resolvedStart, resolvedEnd);
+ rangeBroadcasts = broadcastCampaignRepository.findByCreatedAtBetween(resolvedStart, resolvedEnd);
+
+ totalOwnersCount = userQueryService.countByRoleIn(List.of(Role.ROLE_OWNER, Role.ROLE_ADMIN));
+ hasBots = botRepository.count() > 0;
+ }
long uptimeSeconds = (System.currentTimeMillis() - START_TIME) / 1000;
List growth = buildGrowthMetrics(
- finalStart, finalEnd, rangeUsers, rangeBotUsers, rangeBots, rangeSchemas, rangeBroadcasts, activeOwners);
+ resolvedStart, resolvedEnd, rangeUsers, rangeBotUsers, rangeBots, rangeSchemas, rangeBroadcasts, activeOwners);
- AdminStatsDto.ServerHealthDto serverHealth = buildServerHealth(activeBots, allBots);
+ AdminStatsDto.ServerHealthDto serverHealth = buildServerHealth(activeBots, hasBots);
- List allSubscriptions = subscriptionRepository.findAll();
+ List allSubscriptions = subscriptionRepository.findAllWithPlanAndUser();
double mrrVal = AdminStatsCalculator.calculateMrr(allSubscriptions);
double ltvVal = AdminStatsCalculator.calculateLtv(allSubscriptions);
- LocalDateTime previousStart = resolvedStart.minus(Duration.between(resolvedStart.atZone(ZoneOffset.UTC), resolvedEnd.atZone(ZoneOffset.UTC)));
- List prevUsers = DateTimeUtils.filterByDateRange(allUsers, u -> u.getCreatedAt(), previousStart, finalStart);
- List prevBotUsers = DateTimeUtils.filterByDateRange(allBotUsers, bu -> bu.getCreatedAt(), previousStart, finalStart);
- List prevBots = DateTimeUtils.filterByDateRange(allBots, b -> b.getCreatedAt(), previousStart, finalStart);
- List prevSchemas = DateTimeUtils.filterByDateRange(allSchemas, s -> s.getCreatedAt(), previousStart, finalStart);
- List prevBroadcasts = DateTimeUtils.filterByDateRange(allBroadcasts, bc -> bc.getCreatedAt(), previousStart, finalStart);
-
- long prevOwners = prevUsers.stream().filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN).count();
- long prevBotUsersCount = prevBotUsers.size();
- long prevActiveBots = prevBots.stream().filter(Bot::isActive).count();
- long prevAutomations = prevSchemas.size();
- long prevMessages = prevBroadcasts.stream().mapToLong(c -> c.getSentCount() != null ? c.getSentCount() : 0).sum();
-
- List planDistribution = buildPlanDistribution(allUsers, allSubscriptions);
- List integrationsPopularity = buildIntegrationsPopularity(previousStart, finalStart);
- List geographyAndLanguages = buildGeography(allBotUsers, previousStart, finalStart);
+ List planDistribution = buildPlanDistribution(totalOwnersCount, allSubscriptions);
+ List integrationsPopularity = buildIntegrationsPopularity(resolvedStart);
+ List geographyAndLanguages = buildGeography(resolvedStart);
List latestLogs = adminLogService.getSystemLogs(null, null, null, null, null, "desc", 0, 10).getContent();
List performanceMetrics = AdminStatsCalculator.buildPerformanceMetrics();
- org.springframework.security.core.Authentication auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
boolean isManagerUser = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_MANAGER"));
@@ -275,12 +310,13 @@ private List buildGrowthMetrics(
return growth;
}
- private AdminStatsDto.ServerHealthDto buildServerHealth(long activeBots, List allBots) {
+ private AdminStatsDto.ServerHealthDto buildServerHealth(long activeBots, boolean hasBots) {
boolean dbHealthy = true;
String dbStatus = "Connected";
try {
userQueryService.countTotalUsers();
} catch (Exception e) {
+ log.warn("Database health check ping failed: {}", e.getMessage());
dbHealthy = false;
dbStatus = "Error";
}
@@ -289,7 +325,7 @@ private AdminStatsDto.ServerHealthDto buildServerHealth(long activeBots, List 0 ? "Polling Active" : "Idle")
- .telegramHealthy(activeBots > 0 || !allBots.isEmpty())
+ .telegramHealthy(activeBots > 0 || hasBots)
.aiStatus("Operational")
.aiHealthy(true)
.broadcastStatus("Ready")
@@ -297,11 +333,7 @@ private AdminStatsDto.ServerHealthDto buildServerHealth(long activeBots, List buildPlanDistribution(List allUsers, List allSubscriptions) {
- long totalOwnersCount = allUsers.stream()
- .filter(u -> u.getRole() == Role.ROLE_OWNER || u.getRole() == Role.ROLE_ADMIN)
- .count();
-
+ private List buildPlanDistribution(long totalOwnersCount, List allSubscriptions) {
Map subCountsByPlan = allSubscriptions.stream()
.filter(sub -> sub.getStatus() == SubscriptionStatus.ACTIVE && sub.getPlan() != null)
.collect(Collectors.groupingBy(sub -> sub.getPlan().getDisplayName(), Collectors.counting()));
@@ -319,7 +351,7 @@ private List buildPlanDistribution(List
for (Plan plan : allPlans) {
String displayName = plan.getDisplayName();
long count = subCountsByPlan.getOrDefault(displayName, 0L);
- if (!displayName.equalsIgnoreCase("Free") && (count > 0 || true)) {
+ if (!displayName.equalsIgnoreCase("Free")) {
String color = AdminStatsCalculator.resolvePlanColor(displayName);
distribution.add(new AdminStatsDto.PlanDistributionDto(displayName, count, color));
}
@@ -327,18 +359,24 @@ private List buildPlanDistribution(List
return distribution;
}
- private List buildIntegrationsPopularity(LocalDateTime prevStart, LocalDateTime currentStart) {
- List allIntegrations = integrationRepository.findAll();
- long totalCount = allIntegrations.size();
+ private List buildIntegrationsPopularity(LocalDateTime currentStart) {
+ long totalCount = integrationRepository.count();
- Map currentCounts = allIntegrations.stream()
- .collect(Collectors.groupingBy(Integration::getType, Collectors.counting()));
+ Map currentCounts = new EnumMap<>(IntegrationType.class);
+ List