diff --git a/build.gradle b/build.gradle index bcdd19a..7046df3 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.35' +version = '0.0.36' description = 'Flextuma App' java { diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 2939f5e..ef342eb 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -164,3 +164,137 @@ Examples: - `/settings/profile` These routes will return the client app entry file, while `/api/**` remains reserved for backend APIs. + +## 5. Personal Notifications Integration + +The app also supports user-facing personal notifications for dropdowns, badges, and a full notification center. + +### Personal Notifications List + +- `GET /api/personalNotifications?page=1&pageSize=10` + +Response shape: + +```json +{ + "page": 1, + "total": 12, + "pageSize": 10, + "personalNotifications": [ + { + "id": "0dbe588d-7b7e-4bb9-a6e9-9fa1228a19f4", + "title": "Low Balance Alert", + "message": "Your wallet balance is below TZS 10,000. Current balance: TZS 9500.", + "type": "LOW_BALANCE_ALERT", + "linkUrl": "/finance/wallet", + "readAt": null, + "created": "2026-03-30T16:20:00", + "updated": "2026-03-30T16:20:00" + } + ] +} +``` + +Use this for: +- full “View All Notifications” page +- paginated notification center +- notification history screens + +### Personal Notifications Summary + +- `GET /api/personalNotifications/summary?pageSize=5` + +Use this for the header bell dropdown like the one in your screenshot. + +```json +{ + "unreadCount": 3, + "notifications": [ + { + "id": "0dbe588d-7b7e-4bb9-a6e9-9fa1228a19f4", + "title": "Low Balance Alert", + "message": "Your wallet balance is below TZS 10,000. Current balance: TZS 9500.", + "type": "LOW_BALANCE_ALERT", + "linkUrl": "/finance/wallet", + "readAt": null, + "created": "2026-03-30T16:20:00", + "updated": "2026-03-30T16:20:00" + }, + { + "id": "1f0e8a6a-1d43-48f4-a0ae-0f1b6de8697b", + "title": "Campaign Completed", + "message": "Summer Sale 2024 has finished sending.", + "type": "CAMPAIGN_COMPLETED", + "linkUrl": "/campaigns", + "readAt": null, + "created": "2026-03-30T15:55:00", + "updated": "2026-03-30T15:55:00" + } + ] +} +``` + +### Mark One Notification as Read + +- `POST /api/personalNotifications/{id}/read` + +Recommended when: +- user clicks a notification item +- user opens a notification detail +- user navigates through a notification deep link + +### Mark All Notifications as Read + +- `POST /api/personalNotifications/readAll` + +Example response: + +```json +{ + "message": "Notifications marked as read", + "updated": 3 +} +``` + +### Example Frontend Flow + +```javascript +async function loadPersonalNotificationSummary(pageSize = 5) { + const response = await fetch( + `/api/personalNotifications/summary?pageSize=${pageSize}`, + { credentials: 'include' } + ); + + if (!response.ok) { + throw new Error('Failed to load personal notifications'); + } + + return response.json(); +} + +async function markNotificationRead(id) { + const response = await fetch(`/api/personalNotifications/${id}/read`, { + method: 'POST', + credentials: 'include' + }); + + if (!response.ok) { + throw new Error('Failed to mark notification as read'); + } + + return response.json(); +} +``` + +Recommended UI behavior: +- use `unreadCount` for the blue badge count +- render `notifications` in the dropdown panel +- show relative time from `created` +- navigate to `linkUrl` when present +- for “View All Notifications”, call `GET /api/personalNotifications?page=1&pageSize=10` + +### Current Automatic Notification Types + +These are generated from real backend events: +- `LOW_BALANCE_ALERT` +- `CAMPAIGN_COMPLETED` diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/notification/PersonalNotification.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/notification/PersonalNotification.java new file mode 100644 index 0000000..142b2a0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/notification/PersonalNotification.java @@ -0,0 +1,61 @@ +package com.flexcodelabs.flextuma.core.entities.notification; + +import java.time.LocalDateTime; + +import com.flexcodelabs.flextuma.core.entities.base.Owner; +import com.flexcodelabs.flextuma.core.enums.PersonalNotificationType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "personalnotification", uniqueConstraints = { + @UniqueConstraint(name = "unique_personal_notification_code", columnNames = "code") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class PersonalNotification extends Owner { + + public static final String PLURAL = "personalNotifications"; + public static final String NAME_PLURAL = "Personal Notifications"; + public static final String NAME_SINGULAR = "Personal Notification"; + + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; + + @Column(nullable = false) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String message; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private PersonalNotificationType type; + + @Column(name = "link_url") + private String linkUrl; + + @Column(name = "read_at") + private LocalDateTime readAt; + + @Column(name = "metadata", columnDefinition = "TEXT") + private String metadata; + + public boolean isUnread() { + return readAt == null; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/PersonalNotificationType.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/PersonalNotificationType.java new file mode 100644 index 0000000..231cd66 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/PersonalNotificationType.java @@ -0,0 +1,7 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum PersonalNotificationType { + LOW_BALANCE_ALERT, + CAMPAIGN_COMPLETED, + SYSTEM_UPDATE +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalNotificationRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalNotificationRepository.java new file mode 100644 index 0000000..16fc16f --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/PersonalNotificationRepository.java @@ -0,0 +1,35 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +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.notification.PersonalNotification; +import com.flexcodelabs.flextuma.core.enums.PersonalNotificationType; + +@Repository +public interface PersonalNotificationRepository extends BaseRepository, + org.springframework.data.jpa.repository.JpaSpecificationExecutor { + + long countByCreatedByAndReadAtIsNull(User user); + + List findByCreatedByOrderByCreatedDesc(User user, Pageable pageable); + + Optional findByIdAndCreatedBy(UUID id, User user); + + Optional findByCreatedByAndTypeAndReadAtIsNull(User user, PersonalNotificationType type); + + @org.springframework.data.jpa.repository.Modifying + @org.springframework.data.jpa.repository.Query(""" + UPDATE PersonalNotification p + SET p.readAt = :readAt + WHERE p.createdBy = :user AND p.readAt IS NULL + """) + int markAllAsRead( + @org.springframework.data.repository.query.Param("user") User user, + @org.springframework.data.repository.query.Param("readAt") java.time.LocalDateTime readAt); +} 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 e57dd0e..57a809a 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsCampaignRepository.java @@ -15,13 +15,15 @@ @Repository public interface SmsCampaignRepository extends BaseRepository, - org.springframework.data.jpa.repository.JpaSpecificationExecutor { + org.springframework.data.jpa.repository.JpaSpecificationExecutor { - @Query("SELECT c FROM SmsCampaign c WHERE c.status = :status AND c.scheduledAt <= :now") - List findDueCampaigns( - @Param("status") SmsCampaignStatus status, - @Param("now") LocalDateTime now, - Pageable pageable); + @Query("SELECT c FROM SmsCampaign c WHERE c.status = :status AND c.scheduledAt <= :now") + List findDueCampaigns( + @Param("status") SmsCampaignStatus status, + @Param("now") LocalDateTime now, + Pageable pageable); - long countByCreatedByAndStatusIn(User user, Collection statuses); + long countByCreatedByAndStatusIn(User user, Collection statuses); + + long countByStatusIn(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 2bfa405..efe2ca3 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -37,4 +37,10 @@ List findDueMessages( long countByCreatedByAndStatusInAndCreatedGreaterThanEqual(User user, Collection statuses, LocalDateTime created); + + long countByStatus(SmsLogStatus status); + + long countByStatusIn(Collection statuses); + + long countByStatusInAndCreatedGreaterThanEqual(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 6a53aeb..5d680a6 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java @@ -4,6 +4,7 @@ import com.flexcodelabs.flextuma.core.entities.finance.Wallet; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; @@ -16,4 +17,10 @@ public interface WalletRepository extends JpaRepository, JpaSpecif List findByCreatedByAndBalanceGreaterThan(User user, java.math.BigDecimal balance); java.util.Optional findTopByCreatedByOrderByCreatedDesc(User user); + + @Query("SELECT COALESCE(SUM(w.balance), 0) FROM Wallet w WHERE w.balance IS NOT NULL") + java.math.BigDecimal sumAllBalances(); + + @Query("SELECT w FROM Wallet w WHERE w.currency IS NOT NULL ORDER BY w.created DESC") + java.util.List findTopByCurrencyOrderByCreatedDesc(); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/EntityResponseInitializer.java b/src/main/java/com/flexcodelabs/flextuma/core/services/EntityResponseInitializer.java index 1021aa7..cd84392 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/EntityResponseInitializer.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/EntityResponseInitializer.java @@ -35,31 +35,44 @@ public void initialize(Object entity, int depth) { BeanWrapper wrapper = new BeanWrapperImpl(entity); for (Attribute attribute : managedType.getAttributes()) { - if (!attribute.isAssociation() || !wrapper.isReadableProperty(attribute.getName())) { - continue; - } + processAttribute(wrapper, attribute, depth); + } + } - Object value = wrapper.getPropertyValue(attribute.getName()); - if (value == null) { - continue; - } + private void processAttribute(BeanWrapper wrapper, Attribute attribute, int depth) { + if (!shouldProcessAttribute(wrapper, attribute)) { + return; + } - Hibernate.initialize(value); - if (depth == 0) { - continue; - } + Object value = wrapper.getPropertyValue(attribute.getName()); + if (value == null) { + return; + } - if (value instanceof Collection collection) { - for (Object item : collection) { - initializeSingularAssociations(item, depth - 1); - } - continue; - } + Hibernate.initialize(value); + if (depth > 0) { + processAttributeValue(value, depth); + } + } + private boolean shouldProcessAttribute(BeanWrapper wrapper, Attribute attribute) { + return attribute.isAssociation() && wrapper.isReadableProperty(attribute.getName()); + } + + private void processAttributeValue(Object value, int depth) { + if (value instanceof Collection collection) { + processCollection(collection, depth); + } else { initializeSingularAssociations(value, depth - 1); } } + private void processCollection(Collection collection, int depth) { + for (Object item : collection) { + initializeSingularAssociations(item, depth - 1); + } + } + private void initializeSingularAssociations(Object entity, int depth) { if (entity == null || depth < 0) { return; @@ -73,18 +86,17 @@ private void initializeSingularAssociations(Object entity, int depth) { BeanWrapper wrapper = new BeanWrapperImpl(entity); for (Attribute attribute : managedType.getAttributes()) { - if (!attribute.isAssociation() || attribute.isCollection() || !wrapper.isReadableProperty(attribute.getName())) { + if (!attribute.isAssociation() || attribute.isCollection() + || !wrapper.isReadableProperty(attribute.getName())) { continue; } Object value = wrapper.getPropertyValue(attribute.getName()); - if (value == null) { - continue; - } - - Hibernate.initialize(value); - if (depth > 0) { - initializeSingularAssociations(value, depth - 1); + if (value != null) { + Hibernate.initialize(value); + if (depth > 0) { + initializeSingularAssociations(value, depth - 1); + } } } } 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 index ed8e409..a91e30b 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/dashboard/services/DashboardService.java @@ -25,6 +25,7 @@ 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.core.security.SecurityUtils; import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardNotificationDTO; import com.flexcodelabs.flextuma.modules.dashboard.dtos.DashboardSummaryDTO; @@ -49,22 +50,52 @@ public class DashboardService { @Transactional(readOnly = true) public DashboardSummaryDTO getSummary() { User user = getCurrentUser(); - Wallet wallet = walletRepository.findTopByCreatedByOrderByCreatedDesc(user).orElseGet(Wallet::new); + boolean isAdmin = SecurityUtils.getCurrentUserAuthorities().contains("SUPER_ADMIN"); + + Wallet wallet; + BigDecimal balanceAmount; + String currency; + + if (isAdmin) { + balanceAmount = walletRepository.sumAllBalances(); + currency = "TZS"; + } else { + wallet = walletRepository.findTopByCreatedByOrderByCreatedDesc(user).orElseGet(Wallet::new); + balanceAmount = wallet.getBalance() != null ? wallet.getBalance() : BigDecimal.ZERO; + currency = wallet.getCurrency() != null ? wallet.getCurrency() : "TZS"; + } - 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 successfulMessages; + long failedMessages; + long todayMessages; + long weeklyMessages; + long monthlyMessages; + long activeCampaigns; + long pendingMessages; + long processingMessages; + + if (isAdmin) { + successfulMessages = smsLogRepository.countByStatusIn(SUCCESS_STATUSES); + failedMessages = smsLogRepository.countByStatus(SmsLogStatus.FAILED); + todayMessages = countAllMessagesSince(startOfToday()); + weeklyMessages = countAllMessagesSince(LocalDateTime.now().minusDays(7)); + monthlyMessages = countAllMessagesSince(startOfMonth()); + activeCampaigns = smsCampaignRepository.countByStatusIn(ACTIVE_CAMPAIGN_STATUSES); + pendingMessages = smsLogRepository.countByStatus(SmsLogStatus.PENDING); + processingMessages = smsLogRepository.countByStatus(SmsLogStatus.PROCESSING); + } else { + successfulMessages = smsLogRepository.countByCreatedByAndStatusIn(user, SUCCESS_STATUSES); + failedMessages = smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.FAILED); + todayMessages = countMessagesSince(user, startOfToday()); + weeklyMessages = countMessagesSince(user, LocalDateTime.now().minusDays(7)); + monthlyMessages = countMessagesSince(user, startOfMonth()); + activeCampaigns = smsCampaignRepository.countByCreatedByAndStatusIn(user, ACTIVE_CAMPAIGN_STATUSES); + pendingMessages = smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.PENDING); + processingMessages = smsLogRepository.countByCreatedByAndStatus(user, SmsLogStatus.PROCESSING); + } - 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()) @@ -78,8 +109,9 @@ public DashboardSummaryDTO getSummary() { .thisWeek(weeklyMessages) .thisMonth(monthlyMessages) .successRate(calculateSuccessRate(successfulMessages, failedMessages)) - .statusBreakdown(buildStatusBreakdown(successfulMessages, failedMessages, pendingMessages, processingMessages, - totalStatusCount)) + .statusBreakdown( + buildStatusBreakdown(successfulMessages, failedMessages, pendingMessages, processingMessages, + totalStatusCount)) .build(); } @@ -107,6 +139,10 @@ private long countMessagesSince(User user, LocalDateTime start) { return smsLogRepository.countByCreatedByAndStatusInAndCreatedGreaterThanEqual(user, SUCCESS_STATUSES, start); } + private long countAllMessagesSince(LocalDateTime start) { + return smsLogRepository.countByStatusInAndCreatedGreaterThanEqual(SUCCESS_STATUSES, start); + } + private LocalDateTime startOfToday() { return LocalDate.now().atStartOfDay(); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java index a95f61f..82edb56 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java @@ -7,6 +7,7 @@ import com.flexcodelabs.flextuma.core.repositories.WalletRepository; import com.flexcodelabs.flextuma.core.repositories.WalletTransactionRepository; import com.flexcodelabs.flextuma.core.services.BaseService; +import com.flexcodelabs.flextuma.modules.notification.services.PersonalNotificationService; import lombok.RequiredArgsConstructor; @@ -28,6 +29,7 @@ public class WalletService extends BaseService { private final WalletRepository repository; private final WalletTransactionRepository transactionRepository; + private final PersonalNotificationService personalNotificationService; @Value("${flextuma.sms.price-per-segment:20.0}") private BigDecimal smsPricePerSegment; @@ -62,6 +64,7 @@ public WalletTransaction debit(User user, BigDecimal amount, String description, wallet.setBalance(wallet.getBalance().subtract(amount)); Wallet savedWallet = repository.save(wallet); + personalNotificationService.notifyLowBalance(user, savedWallet.getBalance()); WalletTransaction transaction = new WalletTransaction(); transaction.setWallet(savedWallet); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/PersonalNotificationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/PersonalNotificationController.java new file mode 100644 index 0000000..728c221 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/PersonalNotificationController.java @@ -0,0 +1,42 @@ +package com.flexcodelabs.flextuma.modules.notification.controllers; + +import java.util.Map; +import java.util.UUID; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.http.ResponseEntity; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.notification.PersonalNotification; +import com.flexcodelabs.flextuma.modules.notification.dtos.NotificationSummaryDTO; +import com.flexcodelabs.flextuma.modules.notification.services.PersonalNotificationService; + +@RestController +@RequestMapping("/api/" + PersonalNotification.PLURAL) +public class PersonalNotificationController + extends BaseController { + + public PersonalNotificationController(PersonalNotificationService service) { + super(service); + } + + @GetMapping("/summary") + public ResponseEntity summary(@RequestParam(defaultValue = "5") int pageSize) { + return ResponseEntity.ok(service.getSummary(pageSize)); + } + + @PostMapping("/{id}/read") + public ResponseEntity markAsRead(@PathVariable UUID id) { + return ResponseEntity.ok(service.markAsRead(id)); + } + + @PostMapping("/readAll") + public ResponseEntity> markAllAsRead() { + return ResponseEntity.ok(service.markAllAsRead()); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/dtos/NotificationSummaryDTO.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/dtos/NotificationSummaryDTO.java new file mode 100644 index 0000000..a29e54a --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/dtos/NotificationSummaryDTO.java @@ -0,0 +1,13 @@ +package com.flexcodelabs.flextuma.modules.notification.dtos; + +import java.util.List; + +import com.flexcodelabs.flextuma.core.entities.notification.PersonalNotification; + +import lombok.Builder; + +@Builder +public record NotificationSummaryDTO( + long unreadCount, + List notifications) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java index 2bdb578..d528f6a 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorker.java @@ -30,6 +30,7 @@ public class CampaignDispatchWorker { private final SmsLogRepository logRepository; private final WalletService walletService; private final SmsSegmentCalculator segmentCalculator; + private final PersonalNotificationService personalNotificationService; @Value("${flextuma.sms.price-per-segment:1.0}") private BigDecimal pricePerSegment; @@ -77,6 +78,7 @@ private void processSingleCampaign(SmsCampaign campaign) { campaign.setStatus(SmsCampaignStatus.COMPLETED); campaignRepository.save(campaign); + personalNotificationService.notifyCampaignCompleted(campaign.getCreatedBy(), campaign.getName()); log.info("Campaign [{}] processing completed successfully", campaign.getName()); } catch (Exception e) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationService.java new file mode 100644 index 0000000..95b6ff2 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationService.java @@ -0,0 +1,183 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.notification.PersonalNotification; +import com.flexcodelabs.flextuma.core.enums.PersonalNotificationType; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.PersonalNotificationRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import com.flexcodelabs.flextuma.modules.notification.dtos.NotificationSummaryDTO; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PersonalNotificationService extends BaseService { + + private static final BigDecimal LOW_BALANCE_THRESHOLD = new BigDecimal("10000"); + + private final PersonalNotificationRepository repository; + private final CurrentUserResolver currentUserResolver; + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected String getReadPermission() { + return PersonalNotification.READ; + } + + @Override + protected String getAddPermission() { + return PersonalNotification.ADD; + } + + @Override + protected String getUpdatePermission() { + return PersonalNotification.UPDATE; + } + + @Override + protected String getDeletePermission() { + return PersonalNotification.DELETE; + } + + @Override + public String getEntityPlural() { + return PersonalNotification.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return PersonalNotification.PLURAL; + } + + @Override + protected String getEntitySingular() { + return PersonalNotification.NAME_SINGULAR; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected String getTableName() { + return "personalnotification"; + } + + @Override + protected void onPreSave(PersonalNotification entity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, + "Personal notifications cannot be created manually"); + } + + @Override + protected PersonalNotification onPreUpdate(PersonalNotification newEntity, PersonalNotification oldEntity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, + "Personal notifications cannot be updated manually"); + } + + @Override + protected void validateDelete(PersonalNotification entity) { + throw new ResponseStatusException(HttpStatus.METHOD_NOT_ALLOWED, + "Personal notifications cannot be deleted manually"); + } + + @Transactional(readOnly = true) + public NotificationSummaryDTO getSummary(int pageSize) { + User user = getCurrentUser(); + int safeLimit = Math.max(1, Math.min(pageSize, 20)); + List notifications = repository.findByCreatedByOrderByCreatedDesc(user, + PageRequest.of(0, safeLimit)); + + notifications.forEach(this::initializeAssociationsForResponse); + + return NotificationSummaryDTO.builder() + .unreadCount(repository.countByCreatedByAndReadAtIsNull(user)) + .notifications(notifications) + .build(); + } + + @Transactional + public PersonalNotification markAsRead(UUID id) { + User user = getCurrentUser(); + PersonalNotification notification = repository.findByIdAndCreatedBy(id, user) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Notification not found")); + + if (notification.getReadAt() == null) { + notification.setReadAt(LocalDateTime.now()); + } + + PersonalNotification saved = repository.save(notification); + initializeAssociationsForResponse(saved); + return saved; + } + + @Transactional + public Map markAllAsRead() { + User user = getCurrentUser(); + int updated = repository.markAllAsRead(user, LocalDateTime.now()); + return Map.of("message", "Notifications marked as read", "updated", updated); + } + + @Transactional + public void notifyLowBalance(User user, BigDecimal balance) { + if (user == null || balance == null || balance.compareTo(LOW_BALANCE_THRESHOLD) >= 0) { + return; + } + + PersonalNotification notification = repository.findByCreatedByAndTypeAndReadAtIsNull(user, + PersonalNotificationType.LOW_BALANCE_ALERT) + .orElseGet(PersonalNotification::new); + + notification.setCreatedBy(user); + notification.setType(PersonalNotificationType.LOW_BALANCE_ALERT); + notification.setTitle("Low Balance Alert"); + notification.setMessage("Your wallet balance is below TZS 10,000. Current balance: TZS " + + balance.stripTrailingZeros().toPlainString() + "."); + notification.setLinkUrl("/finance/wallet"); + notification.setCode("LOW_BALANCE_" + user.getId()); + notification.setReadAt(null); + repository.save(notification); + } + + @Transactional + public void notifyCampaignCompleted(User user, String campaignName) { + if (user == null) { + return; + } + + PersonalNotification notification = new PersonalNotification(); + notification.setCreatedBy(user); + notification.setType(PersonalNotificationType.CAMPAIGN_COMPLETED); + notification.setTitle("Campaign Completed"); + notification.setMessage((campaignName == null || campaignName.isBlank() ? "Your campaign" + : campaignName) + " has finished sending."); + notification.setLinkUrl("/campaigns"); + notification.setCode("CAMPAIGN_COMPLETED_" + UUID.randomUUID()); + repository.save(notification); + } + + private User getCurrentUser() { + return currentUserResolver.getCurrentUser() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required")); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/PersonalNotificationControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/PersonalNotificationControllerTest.java new file mode 100644 index 0000000..5408816 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/controllers/PersonalNotificationControllerTest.java @@ -0,0 +1,112 @@ +package com.flexcodelabs.flextuma.modules.notification.controllers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +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.data.domain.Pageable; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.flexcodelabs.flextuma.core.dtos.Pagination; +import com.flexcodelabs.flextuma.core.entities.notification.PersonalNotification; +import com.flexcodelabs.flextuma.modules.notification.dtos.NotificationSummaryDTO; +import com.flexcodelabs.flextuma.modules.notification.services.PersonalNotificationService; + +@ExtendWith(MockitoExtension.class) +class PersonalNotificationControllerTest { + + @Mock + private PersonalNotificationService service; + + private MockMvc mockMvc; + private PersonalNotificationController controller; + + @BeforeEach + void setUp() { + org.springframework.data.web.PageableHandlerMethodArgumentResolver resolver = new org.springframework.data.web.PageableHandlerMethodArgumentResolver(); + resolver.setOneIndexedParameters(true); + controller = new PersonalNotificationController(service); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(resolver) + .build(); + } + + @Test + void summary_shouldReturnUnreadCountAndNotifications() throws Exception { + PersonalNotification notification = new PersonalNotification(); + notification.setTitle("Low Balance Alert"); + + NotificationSummaryDTO summary = NotificationSummaryDTO.builder() + .unreadCount(3) + .notifications(List.of(notification)) + .build(); + + when(service.getSummary(5)).thenReturn(summary); + + mockMvc.perform(get("/api/personalNotifications/summary").param("pageSize", "5")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.unreadCount").value(3)) + .andExpect(jsonPath("$.notifications[0].title").value("Low Balance Alert")); + } + + @Test + void markAsRead_shouldReturnNotification() throws Exception { + UUID id = UUID.randomUUID(); + PersonalNotification notification = new PersonalNotification(); + notification.setId(id); + notification.setTitle("Campaign Completed"); + + when(service.markAsRead(id)).thenReturn(notification); + + mockMvc.perform(post("/api/personalNotifications/{id}/read", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("Campaign Completed")); + } + + @Test + void markAllAsRead_shouldReturnUpdateCount() throws Exception { + when(service.markAllAsRead()).thenReturn(Map.of("message", "Notifications marked as read", "updated", 4)); + org.springframework.http.ResponseEntity> response = controller.markAllAsRead(); + assertEquals(org.springframework.http.HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + assertEquals(4, response.getBody().get("updated")); + } + + @Test + void getAll_shouldReturnPaginatedNotifications() throws Exception { + PersonalNotification notification = new PersonalNotification(); + notification.setId(UUID.randomUUID()); + notification.setTitle("System Update"); + + Pagination pagination = Pagination.builder() + .page(1) + .total(1) + .pageSize(10) + .data(List.of(notification)) + .build(); + + when(service.findAllPaginated(org.mockito.ArgumentMatchers.any(Pageable.class), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())).thenReturn(pagination); + when(service.getPropertyName()).thenReturn("personalNotifications"); + + mockMvc.perform(get("/api/personalNotifications")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.personalNotifications[0].title").value("System Update")); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java index ad194b8..06885ef 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/CampaignDispatchWorkerTest.java @@ -1,34 +1,40 @@ package com.flexcodelabs.flextuma.modules.notification.services; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; -import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Pageable; -import org.springframework.test.util.ReflectionTestUtils; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.List; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class CampaignDispatchWorkerTest { @@ -45,6 +51,9 @@ class CampaignDispatchWorkerTest { @Mock private SmsSegmentCalculator segmentCalculator; + @Mock + private PersonalNotificationService personalNotificationService; + @InjectMocks private CampaignDispatchWorker worker; @@ -90,6 +99,7 @@ void processCampaigns_withDueCampaigns_shouldProcessThem() { verify(campaignRepository, atLeastOnce()).save(campaign); verify(walletService, times(2)).debit(eq(adminUser), any(BigDecimal.class), anyString(), any()); verify(logRepository, times(2)).save(any(SmsLog.class)); + verify(personalNotificationService).notifyCampaignCompleted(adminUser, "Test Campaign"); assert (campaign.getStatus() == SmsCampaignStatus.COMPLETED); } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java index f1eff93..e472142 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationServiceTest.java @@ -9,6 +9,7 @@ import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import com.flexcodelabs.flextuma.core.services.EntityResponseInitializer; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -61,6 +62,9 @@ class NotificationServiceTest { @Mock private SmsSegmentCalculator segmentCalculator; + @Mock + private EntityResponseInitializer entityResponseInitializer; + @InjectMocks private NotificationService notificationService; diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationServiceTest.java new file mode 100644 index 0000000..a7274bf --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/PersonalNotificationServiceTest.java @@ -0,0 +1,138 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.notification.PersonalNotification; +import com.flexcodelabs.flextuma.core.enums.PersonalNotificationType; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.PersonalNotificationRepository; +import com.flexcodelabs.flextuma.core.security.SecurityUtils; +import com.flexcodelabs.flextuma.core.services.EntityResponseInitializer; +import com.flexcodelabs.flextuma.modules.notification.dtos.NotificationSummaryDTO; + +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +@ExtendWith(MockitoExtension.class) +class PersonalNotificationServiceTest { + + @Mock + private PersonalNotificationRepository repository; + + @Mock + private CurrentUserResolver currentUserResolver; + + @Mock + private EntityResponseInitializer entityResponseInitializer; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private PersonalNotificationService service; + private MockedStatic securityUtilsMock; + + @BeforeEach + void setUp() { + service = new PersonalNotificationService(repository, currentUserResolver); + service.setEntityResponseInitializer(entityResponseInitializer); + service.setEventPublisher(eventPublisher); + securityUtilsMock = Mockito.mockStatic(SecurityUtils.class); + securityUtilsMock.when(SecurityUtils::getCurrentUserAuthorities).thenReturn(java.util.Set.of("ALL")); + } + + @org.junit.jupiter.api.AfterEach + void tearDown() { + securityUtilsMock.close(); + } + + @Test + void getSummary_shouldReturnUnreadCountAndRecentNotifications() { + User user = user("admin"); + PersonalNotification notification = new PersonalNotification(); + notification.setTitle("Low Balance Alert"); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(repository.findByCreatedByOrderByCreatedDesc(eq(user), any())).thenReturn(List.of(notification)); + when(repository.countByCreatedByAndReadAtIsNull(user)).thenReturn(3L); + + NotificationSummaryDTO summary = service.getSummary(5); + + assertEquals(3L, summary.unreadCount()); + assertEquals(1, summary.notifications().size()); + verify(entityResponseInitializer).initialize(notification); + } + + @Test + void notifyLowBalance_shouldCreateUnreadLowBalanceNotification() { + User user = user("admin"); + ArgumentCaptor captor = ArgumentCaptor.forClass(PersonalNotification.class); + + when(repository.findByCreatedByAndTypeAndReadAtIsNull(user, PersonalNotificationType.LOW_BALANCE_ALERT)) + .thenReturn(Optional.empty()); + + service.notifyLowBalance(user, new BigDecimal("9500")); + + verify(repository).save(captor.capture()); + PersonalNotification saved = captor.getValue(); + assertEquals("Low Balance Alert", saved.getTitle()); + assertEquals(PersonalNotificationType.LOW_BALANCE_ALERT, saved.getType()); + assertEquals("/finance/wallet", saved.getLinkUrl()); + assertEquals("LOW_BALANCE_" + user.getId(), saved.getCode()); + } + + @Test + void markAsRead_shouldSetReadTimestamp() { + User user = user("admin"); + UUID notificationId = UUID.randomUUID(); + PersonalNotification notification = new PersonalNotification(); + notification.setId(notificationId); + + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(repository.findByIdAndCreatedBy(notificationId, user)).thenReturn(Optional.of(notification)); + when(repository.save(notification)).thenReturn(notification); + + PersonalNotification result = service.markAsRead(notificationId); + + assertNotNull(result.getReadAt()); + verify(entityResponseInitializer).initialize(notification); + } + + @Test + void markAllAsRead_shouldReturnUpdatedCount() { + User user = user("admin"); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(repository.markAllAsRead(eq(user), any(LocalDateTime.class))).thenReturn(4); + + Map response = service.markAllAsRead(); + + assertEquals(4, response.get("updated")); + } + + private User user(String username) { + User user = new User(); + user.setId(UUID.randomUUID()); + user.setUsername(username); + return user; + } +}