From 4a85a4daad22a8fef1b17b0fe3fa26f4ff000fca Mon Sep 17 00:00:00 2001 From: Bennett Date: Sun, 29 Mar 2026 22:55:12 +0300 Subject: [PATCH 1/2] Add dashbaord and notifications analytics --- docs/frontend-integration.md | 127 +++++++++++++ .../core/helpers/CurrentUserResolver.java | 19 +- .../repositories/SmsCampaignRepository.java | 4 + .../core/repositories/SmsLogRepository.java | 14 ++ .../core/repositories/WalletRepository.java | 2 + .../app/controllers/FrontendController.java | 16 +- .../auth/controllers/AuthController.java | 5 +- .../controllers/DashboardController.java | 24 +++ .../dtos/DashboardNotificationDTO.java | 17 ++ .../dashboard/dtos/DashboardSummaryDTO.java | 24 +++ .../dashboard/services/DashboardService.java | 171 ++++++++++++++++++ .../controllers/NotificationController.java | 16 +- .../controllers/FrontendControllerTest.java | 55 ++++++ .../controllers/DashboardControllerTest.java | 62 +++++++ .../services/DashboardServiceTest.java | 135 ++++++++++++++ .../NotificationControllerTest.java | 42 ++++- 16 files changed, 726 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/dashboard/controllers/DashboardController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/dashboard/dtos/DashboardNotificationDTO.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/dashboard/dtos/DashboardSummaryDTO.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardService.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/app/controllers/FrontendControllerTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/dashboard/controllers/DashboardControllerTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardServiceTest.java diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 7105db7..2939f5e 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -37,3 +37,130 @@ The API uses standard HTTP status codes: - `401 Unauthorized`: Invalid PAT or session. - `403 Forbidden`: Insufficient permissions. - `429 Too Many Requests`: Rate limit exceeded (Bucket4j). + +## 4. Dashboard Integration + +The dashboard can now use user-scoped summary data directly from the backend. All data below is filtered to the currently authenticated user. + +### Summary Endpoint + +- `GET /api/dashboard/summary` + +Example response: + +```json +{ + "userId": "6269df23-f8a0-4776-bd89-3015521bc19d", + "username": "admin", + "sent": 2847, + "failed": 23, + "balanceAmount": 50000, + "balance": "TZS 50000", + "currency": "TZS", + "activeCampaigns": 3, + "today": 156, + "thisWeek": 892, + "thisMonth": 2847, + "successRate": 99.2, + "statusBreakdown": { + "sent": 84.5, + "failed": 0.7, + "pending": 10.3, + "other": 4.5 + } +} +``` + +How to use it: +- KPI cards: `sent`, `failed`, `balance`, `activeCampaigns` +- Time cards: `today`, `thisWeek`, `thisMonth` +- Success widget: `successRate` +- Doughnut or stacked chart: `statusBreakdown` + +Example fetch: + +```javascript +const response = await fetch('/api/dashboard/summary', { + method: 'GET', + credentials: 'include' +}); + +if (!response.ok) { + throw new Error('Failed to load dashboard summary'); +} + +const summary = await response.json(); +``` + +### Notifications Feed + +- `GET /api/notifications?page=1&pageSize=15` + +This endpoint now follows the project pagination style instead of using a custom limit parameter. + +Example response: + +```json +{ + "page": 1, + "total": 42, + "pageSize": 15, + "data": [ + { + "id": "2f7fcb14-f99d-4983-9a27-104e55f96eb1", + "phoneNumber": "+255700000000", + "message": "Your OTP is 1234", + "status": "delivered", + "provider": "BEEM", + "createdAt": "2026-03-29T12:00:00", + "updatedAt": "2026-03-29T12:00:10" + } + ] +} +``` + +Recommended usage: +- Recent activity list: render `data` +- Pager or infinite scroll: use `page`, `pageSize`, and `total` +- Status pill: map `sent`, `delivered`, `failed`, `pending`, `processing` + +Example fetch: + +```javascript +async function loadNotifications(page = 1, pageSize = 15) { + const response = await fetch( + `/api/notifications?page=${page}&pageSize=${pageSize}`, + { credentials: 'include' } + ); + + if (!response.ok) { + throw new Error('Failed to load notifications'); + } + + return response.json(); +} +``` + +### Existing Endpoints Still Available + +The dashboard can still consume the existing raw resources when needed: +- `GET /api/wallets` +- `GET /api/campaigns` +- `GET /api/smsLogs` +- `POST /api/logout` + +Recommended pattern: +- Use `/api/dashboard/summary` for top-level dashboard widgets +- Use `/api/notifications` for recent message activity +- Use the existing resource endpoints for full management pages and drill-down tables + +### SPA Routing + +The backend now serves the frontend `index.html` for any non-API route. + +Examples: +- `/dashboard` +- `/dashboard/campaigns` +- `/settings/profile` + +These routes will return the client app entry file, while `/api/**` remains reserved for backend APIs. diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java index 4c09042..122f547 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/CurrentUserResolver.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import java.util.Optional; @@ -24,9 +25,25 @@ public class CurrentUserResolver { public Optional getCurrentUser() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() || !(auth.getPrincipal() instanceof String username)) { + if (auth == null || !auth.isAuthenticated()) { return Optional.empty(); } + + Object principal = auth.getPrincipal(); + String username = null; + + if (principal instanceof UserDetails userDetails) { + username = userDetails.getUsername(); + } else if (principal instanceof String principalName) { + username = principalName; + } else if (auth.getName() != null && !auth.getName().isBlank()) { + username = auth.getName(); + } + + if (username == null || "anonymousUser".equalsIgnoreCase(username)) { + return Optional.empty(); + } + return userRepository.findByUsername(username); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java index 64fc361..e57dd0e 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java @@ -1,5 +1,6 @@ package com.flexcodelabs.flextuma.core.repositories; +import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; import org.springframework.data.domain.Pageable; @@ -8,6 +9,7 @@ import org.springframework.stereotype.Repository; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.UUID; @@ -20,4 +22,6 @@ List findDueCampaigns( @Param("status") SmsCampaignStatus status, @Param("now") LocalDateTime now, Pageable pageable); + + long countByCreatedByAndStatusIn(User user, Collection statuses); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java index 2b1ffaf..2bfa405 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -1,12 +1,17 @@ package com.flexcodelabs.flextuma.core.repositories; +import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.UUID; +import org.springframework.data.domain.Page; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; @@ -23,4 +28,13 @@ List findDueMessages( org.springframework.data.domain.Pageable pageable); Optional findByProviderResponse(String providerResponse); + + Page findByCreatedByOrderByCreatedDesc(User user, Pageable pageable); + + long countByCreatedByAndStatus(User user, SmsLogStatus status); + + long countByCreatedByAndStatusIn(User user, Collection statuses); + + long countByCreatedByAndStatusInAndCreatedGreaterThanEqual(User user, Collection statuses, + LocalDateTime created); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java index 88dc543..6a53aeb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java @@ -14,4 +14,6 @@ public interface WalletRepository extends JpaRepository, JpaSpecif List findByCreatedBy(User user); List findByCreatedByAndBalanceGreaterThan(User user, java.math.BigDecimal balance); + + java.util.Optional findTopByCreatedByOrderByCreatedDesc(User user); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/app/controllers/FrontendController.java b/src/main/java/com/flexcodelabs/flextuma/modules/app/controllers/FrontendController.java index 03172b9..42c879d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/app/controllers/FrontendController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/app/controllers/FrontendController.java @@ -40,11 +40,23 @@ public ResponseEntity serveCatchAll(HttpServletRequest request) { return serveStatic(indexFile); } - if (!path.contains(".")) { + if (path.startsWith("api/")) { + return ResponseEntity.notFound().build(); + } + + if (path.startsWith("assets/")) { + return serveStatic(path); + } + + if (path.equals("favicon.ico")) { + return serveStatic(path); + } + + if (path.startsWith(".well-known/")) { return serveStatic(indexFile); } - return serveStatic(path); + return serveStatic(indexFile); } private ResponseEntity serveStatic(String path) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java index 217fc07..21e3a50 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java @@ -1,6 +1,7 @@ package com.flexcodelabs.flextuma.modules.auth.controllers; import java.math.BigDecimal; +import java.util.Map; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -100,7 +101,7 @@ public ResponseEntity login( } @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) { securityLogService.logLogout(auth.getName(), request); @@ -118,7 +119,7 @@ public ResponseEntity logout(HttpServletRequest request, HttpServletRespon cookie.setMaxAge(0); response.addCookie(cookie); - return ResponseEntity.ok().build(); + return ResponseEntity.ok(Map.of("message", "Logged out successfully")); } @GetMapping("/me") diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/controllers/DashboardController.java b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/controllers/DashboardController.java new file mode 100644 index 0000000..1dc4f31 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/controllers/DashboardController.java @@ -0,0 +1,24 @@ +package com.flexcodelabs.flextuma.modules.dashboard.controllers; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardSummaryDTO; +import com.flexcodelabs.flextuma.modules.dashboard.services.DashboardService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + + @GetMapping("/summary") + public ResponseEntity getSummary() { + return ResponseEntity.ok(dashboardService.getSummary()); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/dtos/DashboardNotificationDTO.java b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/dtos/DashboardNotificationDTO.java new file mode 100644 index 0000000..9444c04 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/dtos/DashboardNotificationDTO.java @@ -0,0 +1,17 @@ +package com.flexcodelabs.flextuma.modules.dashboard.dtos; + +import java.time.LocalDateTime; +import java.util.UUID; + +import lombok.Builder; + +@Builder +public record DashboardNotificationDTO( + UUID id, + String phoneNumber, + String message, + String status, + String provider, + LocalDateTime createdAt, + LocalDateTime updatedAt) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/dtos/DashboardSummaryDTO.java b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/dtos/DashboardSummaryDTO.java new file mode 100644 index 0000000..8317782 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/dtos/DashboardSummaryDTO.java @@ -0,0 +1,24 @@ +package com.flexcodelabs.flextuma.modules.dashboard.dtos; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; + +import lombok.Builder; + +@Builder +public record DashboardSummaryDTO( + UUID userId, + String username, + long sent, + long failed, + BigDecimal balanceAmount, + String balance, + String currency, + long activeCampaigns, + long today, + long thisWeek, + long thisMonth, + double successRate, + Map statusBreakdown) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardService.java b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardService.java new file mode 100644 index 0000000..ed8e409 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardService.java @@ -0,0 +1,171 @@ +package com.flexcodelabs.flextuma.modules.dashboard.services; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import com.flexcodelabs.flextuma.core.dtos.Pagination; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.finance.Wallet; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.repositories.WalletRepository; +import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardNotificationDTO; +import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardSummaryDTO; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.HttpStatus; + +@Service +@RequiredArgsConstructor +public class DashboardService { + + private static final EnumSet SUCCESS_STATUSES = EnumSet.of(SmsLogStatus.SENT, SmsLogStatus.DELIVERED); + private static final EnumSet ACTIVE_CAMPAIGN_STATUSES = EnumSet.of( + SmsCampaignStatus.SCHEDULED, + SmsCampaignStatus.PROCESSING); + + private final CurrentUserResolver currentUserResolver; + private final WalletRepository walletRepository; + private final SmsCampaignRepository smsCampaignRepository; + private final SmsLogRepository smsLogRepository; + + @Transactional(readOnly = true) + public DashboardSummaryDTO getSummary() { + User user = getCurrentUser(); + Wallet wallet = walletRepository.findTopByCreatedByOrderByCreatedDesc(user).orElseGet(Wallet::new); + + long successfulMessages = smsLogRepository.countByCreatedByAndStatusIn(user, SUCCESS_STATUSES); + long failedMessages = smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.FAILED); + long todayMessages = countMessagesSince(user, startOfToday()); + long weeklyMessages = countMessagesSince(user, LocalDateTime.now().minusDays(7)); + long monthlyMessages = countMessagesSince(user, startOfMonth()); + long activeCampaigns = smsCampaignRepository.countByCreatedByAndStatusIn(user, ACTIVE_CAMPAIGN_STATUSES); + + long pendingMessages = smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.PENDING); + long processingMessages = smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.PROCESSING); + long totalStatusCount = successfulMessages + failedMessages + pendingMessages + processingMessages; + + BigDecimal balanceAmount = wallet.getBalance() != null ? wallet.getBalance() : BigDecimal.ZERO; + String currency = wallet.getCurrency() != null ? wallet.getCurrency() : "TZS"; + + return DashboardSummaryDTO.builder() + .userId(user.getId()) + .username(user.getUsername()) + .sent(successfulMessages) + .failed(failedMessages) + .balanceAmount(balanceAmount) + .balance(currency + " " + balanceAmount.stripTrailingZeros().toPlainString()) + .currency(currency) + .activeCampaigns(activeCampaigns) + .today(todayMessages) + .thisWeek(weeklyMessages) + .thisMonth(monthlyMessages) + .successRate(calculateSuccessRate(successfulMessages, failedMessages)) + .statusBreakdown(buildStatusBreakdown(successfulMessages, failedMessages, pendingMessages, processingMessages, + totalStatusCount)) + .build(); + } + + @Transactional(readOnly = true) + public Pagination getRecentNotifications(Pageable pageable) { + User user = getCurrentUser(); + int safePage = Math.max(pageable.getPageNumber(), 0); + int safePageSize = Math.max(1, Math.min(pageable.getPageSize(), 100)); + Page page = smsLogRepository.findByCreatedByOrderByCreatedDesc(user, + PageRequest.of(safePage, safePageSize)); + + List notifications = page.getContent().stream() + .map(this::toNotificationDto) + .toList(); + + return Pagination.builder() + .page(safePage + 1) + .total(page.getTotalElements()) + .pageSize(safePageSize) + .data(notifications) + .build(); + } + + private long countMessagesSince(User user, LocalDateTime start) { + return smsLogRepository.countByCreatedByAndStatusInAndCreatedGreaterThanEqual(user, SUCCESS_STATUSES, start); + } + + private LocalDateTime startOfToday() { + return LocalDate.now().atStartOfDay(); + } + + private LocalDateTime startOfMonth() { + return LocalDate.now().withDayOfMonth(1).atStartOfDay(); + } + + private double calculateSuccessRate(long successfulMessages, long failedMessages) { + long totalProcessed = successfulMessages + failedMessages; + if (totalProcessed == 0) { + return 0.0; + } + + return BigDecimal.valueOf(successfulMessages * 100.0 / totalProcessed) + .setScale(2, RoundingMode.HALF_UP) + .doubleValue(); + } + + private LinkedHashMap buildStatusBreakdown(long successfulMessages, long failedMessages, + long pendingMessages, long processingMessages, long totalStatusCount) { + LinkedHashMap breakdown = new LinkedHashMap<>(); + breakdown.put("sent", percentage(successfulMessages, totalStatusCount)); + breakdown.put("failed", percentage(failedMessages, totalStatusCount)); + breakdown.put("pending", percentage(pendingMessages, totalStatusCount)); + breakdown.put("other", percentage(processingMessages, totalStatusCount)); + return breakdown; + } + + private double percentage(long value, long total) { + if (total == 0) { + return 0.0; + } + return BigDecimal.valueOf(value * 100.0 / total) + .setScale(2, RoundingMode.HALF_UP) + .doubleValue(); + } + + private DashboardNotificationDTO toNotificationDto(SmsLog smsLog) { + return DashboardNotificationDTO.builder() + .id(smsLog.getId()) + .phoneNumber(smsLog.getRecipient()) + .message(smsLog.getContent()) + .status(smsLog.getStatus() != null ? smsLog.getStatus().name().toLowerCase() : null) + .provider(resolveProvider(smsLog)) + .createdAt(smsLog.getCreated()) + .updatedAt(smsLog.getUpdated()) + .build(); + } + + private String resolveProvider(SmsLog smsLog) { + if (smsLog.getConnector() == null || smsLog.getConnector().getProvider() == null) { + return null; + } + return smsLog.getConnector().getProvider().toUpperCase(); + } + + private User getCurrentUser() { + return currentUserResolver.getCurrentUser() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required")); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java index d2b36b2..b18ded1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java @@ -1,10 +1,14 @@ package com.flexcodelabs.flextuma.modules.notification.controllers; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardNotificationDTO; +import com.flexcodelabs.flextuma.modules.dashboard.services.DashboardService; import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; import java.util.Map; @@ -15,6 +19,16 @@ public class NotificationController { private final NotificationService notificationService; + private final DashboardService dashboardService; + + @GetMapping() + public ResponseEntity> listRecentNotifications( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "15") int pageSize) { + int safePage = Math.max(1, page); + int safePageSize = Math.max(1, pageSize); + return ResponseEntity.ok(dashboardService.getRecentNotifications(PageRequest.of(safePage - 1, safePageSize))); + } @PostMapping() public ResponseEntity send( @@ -35,4 +49,4 @@ public ResponseEntity sendRaw( return ResponseEntity.ok(log); } -} \ No newline at end of file +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/app/controllers/FrontendControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/app/controllers/FrontendControllerTest.java new file mode 100644 index 0000000..9ceec87 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/app/controllers/FrontendControllerTest.java @@ -0,0 +1,55 @@ +package com.flexcodelabs.flextuma.modules.app.controllers; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class FrontendControllerTest { + + @TempDir + Path tempDir; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() throws Exception { + Files.writeString(tempDir.resolve("index.html"), "app"); + Files.createDirectories(tempDir.resolve("assets")); + Files.writeString(tempDir.resolve("assets/app.js"), "console.log('ok');"); + + FrontendController controller = new FrontendController(); + ReflectionTestUtils.setField(controller, "frontendDirectory", tempDir.toString()); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void serveCatchAll_shouldReturnIndexForNonApiRoutes() throws Exception { + mockMvc.perform(get("/dashboard/overview")) + .andExpect(status().isOk()) + .andExpect(content().string("app")); + } + + @Test + void serveCatchAll_shouldReturnIndexForDottedNonApiRoutes() throws Exception { + mockMvc.perform(get("/foo.bar")) + .andExpect(status().isOk()) + .andExpect(content().string("app")); + } + + @Test + void serveAsset_shouldReturnStaticAsset() throws Exception { + mockMvc.perform(get("/assets/app.js")) + .andExpect(status().isOk()) + .andExpect(content().string("console.log('ok');")); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/dashboard/controllers/DashboardControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/dashboard/controllers/DashboardControllerTest.java new file mode 100644 index 0000000..122b21d --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/dashboard/controllers/DashboardControllerTest.java @@ -0,0 +1,62 @@ +package com.flexcodelabs.flextuma.modules.dashboard.controllers; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardSummaryDTO; +import com.flexcodelabs.flextuma.modules.dashboard.services.DashboardService; + +@ExtendWith(MockitoExtension.class) +class DashboardControllerTest { + + @Mock + private DashboardService dashboardService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new DashboardController(dashboardService)).build(); + } + + @Test + void getSummary_shouldReturnDashboardData() throws Exception { + DashboardSummaryDTO summary = DashboardSummaryDTO.builder() + .userId(UUID.randomUUID()) + .username("admin") + .sent(10) + .failed(2) + .balanceAmount(new BigDecimal("50000")) + .balance("TZS 50000") + .currency("TZS") + .activeCampaigns(3) + .today(2) + .thisWeek(6) + .thisMonth(10) + .successRate(83.33) + .statusBreakdown(Map.of("sent", 80.0, "failed", 20.0, "pending", 0.0, "other", 0.0)) + .build(); + + when(dashboardService.getSummary()).thenReturn(summary); + + mockMvc.perform(get("/api/dashboard/summary")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("admin")) + .andExpect(jsonPath("$.balance").value("TZS 50000")) + .andExpect(jsonPath("$.activeCampaigns").value(3)); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardServiceTest.java new file mode 100644 index 0000000..c1c9412 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardServiceTest.java @@ -0,0 +1,135 @@ +package com.flexcodelabs.flextuma.modules.dashboard.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.web.server.ResponseStatusException; + +import com.flexcodelabs.flextuma.core.dtos.Pagination; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.finance.Wallet; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.repositories.WalletRepository; +import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardNotificationDTO; +import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardSummaryDTO; + +@ExtendWith(MockitoExtension.class) +class DashboardServiceTest { + + @Mock + private CurrentUserResolver currentUserResolver; + + @Mock + private WalletRepository walletRepository; + + @Mock + private SmsCampaignRepository smsCampaignRepository; + + @Mock + private SmsLogRepository smsLogRepository; + + @Test + void getSummary_shouldReturnUserScopedDashboardMetrics() { + DashboardService service = new DashboardService(currentUserResolver, walletRepository, smsCampaignRepository, + smsLogRepository); + User user = createUser(); + Wallet wallet = new Wallet(); + wallet.setBalance(new BigDecimal("50000")); + wallet.setCurrency("TZS"); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(walletRepository.findTopByCreatedByOrderByCreatedDesc(user)).thenReturn(Optional.of(wallet)); + when(smsLogRepository.countByCreatedByAndStatusIn(eq(user), any())).thenReturn(20L); + when(smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.FAILED)).thenReturn(5L); + when(smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.PENDING)).thenReturn(3L); + when(smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.PROCESSING)).thenReturn(2L); + when(smsLogRepository.countByCreatedByAndStatusInAndCreatedGreaterThanEqual(eq(user), any(), any())) + .thenReturn(4L, 10L, 20L); + when(smsCampaignRepository.countByCreatedByAndStatusIn(eq(user), any())).thenReturn(3L); + + DashboardSummaryDTO summary = service.getSummary(); + + assertEquals("admin", summary.username()); + assertEquals(20L, summary.sent()); + assertEquals(5L, summary.failed()); + assertEquals("TZS 50000", summary.balance()); + assertEquals(3L, summary.activeCampaigns()); + assertEquals(4L, summary.today()); + assertEquals(10L, summary.thisWeek()); + assertEquals(20L, summary.thisMonth()); + assertEquals(80.0, summary.successRate()); + assertEquals(66.67, summary.statusBreakdown().get("sent")); + } + + @Test + void getRecentNotifications_shouldReturnMappedLogsForCurrentUser() { + DashboardService service = new DashboardService(currentUserResolver, walletRepository, smsCampaignRepository, + smsLogRepository); + User user = createUser(); + SmsConnector connector = new SmsConnector(); + connector.setProvider("beem"); + + SmsLog log = new SmsLog(); + log.setId(UUID.randomUUID()); + log.setRecipient("+255700000000"); + log.setContent("hello"); + log.setStatus(SmsLogStatus.DELIVERED); + log.setConnector(connector); + log.setCreated(LocalDateTime.of(2026, 3, 29, 10, 0)); + log.setUpdated(LocalDateTime.of(2026, 3, 29, 10, 5)); + + Page page = new PageImpl<>(List.of(log), PageRequest.of(1, 15), 31); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(smsLogRepository.findByCreatedByOrderByCreatedDesc(eq(user), any(Pageable.class))).thenReturn(page); + + Pagination notifications = service.getRecentNotifications(PageRequest.of(1, 15)); + + assertEquals(2, notifications.getPage()); + assertEquals(31, notifications.getTotal()); + assertEquals(15, notifications.getPageSize()); + assertEquals(1, notifications.getData().size()); + assertEquals("+255700000000", notifications.getData().get(0).phoneNumber()); + assertEquals("delivered", notifications.getData().get(0).status()); + assertEquals("BEEM", notifications.getData().get(0).provider()); + } + + @Test + void getSummary_shouldThrowUnauthorized_whenNoCurrentUser() { + DashboardService service = new DashboardService(currentUserResolver, walletRepository, smsCampaignRepository, + smsLogRepository); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.empty()); + + assertThrows(ResponseStatusException.class, service::getSummary); + } + + private User createUser() { + User user = new User(); + user.setId(UUID.randomUUID()); + user.setUsername("admin"); + return user; + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java index 461e45a..38ea67f 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationControllerTest.java @@ -15,15 +15,22 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardNotificationDTO; +import com.flexcodelabs.flextuma.modules.dashboard.services.DashboardService; import com.flexcodelabs.flextuma.modules.notification.services.NotificationService; +import java.time.LocalDateTime; +import java.util.List; + @ExtendWith(MockitoExtension.class) class NotificationControllerTest { @@ -32,14 +39,47 @@ class NotificationControllerTest { @Mock private NotificationService notificationService; + @Mock + private DashboardService dashboardService; + private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach void setUp() { - NotificationController controller = new NotificationController(notificationService); + NotificationController controller = new NotificationController(notificationService, dashboardService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } + @Test + void listRecentNotifications_shouldReturnDashboardNotifications() throws Exception { + DashboardNotificationDTO notification = DashboardNotificationDTO.builder() + .phoneNumber("+255700000000") + .message("hello") + .status("sent") + .provider("BEEM") + .createdAt(LocalDateTime.of(2026, 3, 29, 12, 0)) + .build(); + + Pagination pagination = Pagination.builder() + .page(2) + .total(11) + .pageSize(25) + .data(List.of(notification)) + .build(); + + when(dashboardService.getRecentNotifications(PageRequest.of(1, 25))).thenReturn(pagination); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/api/notifications") + .param("page", "2") + .param("pageSize", "25")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.page").value(2)) + .andExpect(jsonPath("$.total").value(11)) + .andExpect(jsonPath("$.pageSize").value(25)) + .andExpect(jsonPath("$.data[0].phoneNumber").value("+255700000000")) + .andExpect(jsonPath("$.data[0].status").value("sent")); + } + @Test void send_shouldReturnSmsLog_whenParametersValid() throws Exception { Map variables = new HashMap<>(); From 8a0ef9fb199f68b35280e7fc152d1f83a1bdf5be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 29 Mar 2026 19:56:02 +0000 Subject: [PATCH 2/2] Release v0.0.32 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d8a933d..ccefd50 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.31' +version = '0.0.32' description = 'Flextuma App' java {