diff --git a/build.gradle b/build.gradle index 6b788f1..786e91f 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.47' +version = '0.0.48' description = 'Flextuma App' java { diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java index 6497d19..bd7a646 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.flexcodelabs.flextuma.core.entities.base.Owner; import com.flexcodelabs.flextuma.core.enums.CategoryEnum; +import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; import jakarta.persistence.*; import lombok.*; @@ -43,6 +44,10 @@ public class SmsTemplate extends Owner { @Column(columnDefinition = "TEXT", nullable = false) private String content; + @Column(nullable = false, updatable = true) + @Enumerated(EnumType.STRING) + private SmsTemplateStatus status = SmsTemplateStatus.DRAFT; + @Column(nullable = true, updatable = true) @Enumerated(EnumType.STRING) private CategoryEnum category = CategoryEnum.PROMOTIONAL; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsTemplateStatus.java b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsTemplateStatus.java new file mode 100644 index 0000000..7934b1f --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/enums/SmsTemplateStatus.java @@ -0,0 +1,7 @@ +package com.flexcodelabs.flextuma.core.enums; + +public enum SmsTemplateStatus { + DRAFT, + ACTIVE, + INACTIVE +} 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 d6e846d..2f6f238 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 @@ -4,6 +4,7 @@ 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.enums.SmsTemplateStatus; import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; @@ -66,6 +67,14 @@ private void processSingleCampaign(SmsCampaign campaign) { return; } + if (campaign.getTemplate() == null || campaign.getTemplate().getStatus() != SmsTemplateStatus.ACTIVE) { + log.error("Campaign [{}] references a template that is no longer active; cancelling", + campaign.getName()); + campaign.setStatus(SmsCampaignStatus.CANCELLED); + campaignRepository.save(campaign); + return; + } + String[] recipients = recipientsStr.split(","); log.info("Processing campaign [{}] for {} recipients", campaign.getName(), recipients.length); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 92a3606..11bf543 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -13,6 +13,7 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; import com.flexcodelabs.flextuma.core.helpers.TemplateUtils; @@ -65,6 +66,11 @@ public SmsLog queueTemplatedSms(Map placeholders, String usernam .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Template not found or you don't have access to it")); + if (template.getStatus() != SmsTemplateStatus.ACTIVE) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Template is not active"); + } + SmsConnector connector = getConnector(currentUser, providerValue, placeholders.get("connectorId")); String finalMessage = TemplateUtils.fillTemplate(template.getContent(), placeholders); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java index 9ce9701..9167b7f 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignService.java @@ -1,13 +1,18 @@ package com.flexcodelabs.flextuma.modules.sms.services; import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.services.BaseService; import lombok.RequiredArgsConstructor; 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.web.server.ResponseStatusException; import java.util.UUID; @@ -16,6 +21,7 @@ public class SmsCampaignService extends BaseService { private final SmsCampaignRepository repository; + private final SmsTemplateRepository templateRepository; @Override protected JpaRepository getRepository() { @@ -72,6 +78,26 @@ protected void onPreSave(SmsCampaign entity) { if (entity.getStatus() == null) { entity.setStatus(SmsCampaignStatus.SCHEDULED); } + validateTemplateIsActive(entity); + } + + @Override + protected SmsCampaign onPreUpdate(SmsCampaign newEntity, SmsCampaign oldEntity) { + SmsCampaign merged = super.onPreUpdate(newEntity, oldEntity); + validateTemplateIsActive(newEntity); + return merged; + } + + private void validateTemplateIsActive(SmsCampaign entity) { + if (entity.getTemplate() == null || entity.getTemplate().getId() == null) { + return; + } + SmsTemplate template = templateRepository.findById(entity.getTemplate().getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Template not found")); + if (template.getStatus() != SmsTemplateStatus.ACTIVE) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Cannot use an inactive template for a campaign"); + } } @Override 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 a8984dd..f7328de 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 @@ -31,6 +31,7 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus; +import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentCalculator; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; @@ -83,6 +84,7 @@ void processCampaigns_withDueCampaigns_shouldProcessThem() { campaign.setRecipients("255700112233, 255700445566"); SmsTemplate template = new SmsTemplate(); template.setContent("Hello world"); + template.setStatus(SmsTemplateStatus.ACTIVE); campaign.setTemplate(template); SmsConnector connector = new SmsConnector(); campaign.setConnector(connector); @@ -106,6 +108,34 @@ void processCampaigns_withDueCampaigns_shouldProcessThem() { assert (campaign.getStatus() == SmsCampaignStatus.COMPLETED); } + @Test + void processCampaigns_withInactiveTemplate_shouldCancelWithoutDispatching() { + SmsCampaign campaign = new SmsCampaign(); + campaign.setName("Test Campaign"); + campaign.setRecipients("255700112233, 255700445566"); + SmsTemplate template = new SmsTemplate(); + template.setContent("Hello world"); + template.setStatus(SmsTemplateStatus.INACTIVE); + campaign.setTemplate(template); + SmsConnector connector = new SmsConnector(); + campaign.setConnector(connector); + User adminUser = new User(); + adminUser.setUsername("admin"); + campaign.setCreatedBy(adminUser); + + when(campaignRepository.findDueCampaigns(eq(SmsCampaignStatus.SCHEDULED), any(LocalDateTime.class), + any(Pageable.class))) + .thenReturn(List.of(campaign)); + + worker.processCampaigns(); + + verify(campaignRepository, atLeastOnce()).save(campaign); + verify(walletService, never()).debit(any(), any(BigDecimal.class), anyString(), any()); + verify(logRepository, never()).save(any(SmsLog.class)); + verify(personalNotificationService, never()).notifyCampaignCompleted(any(), anyString()); + assert (campaign.getStatus() == SmsCampaignStatus.CANCELLED); + } + @Test void processCampaigns_withEmptyRecipients_shouldCompleteImmediately() { SmsCampaign campaign = new SmsCampaign(); @@ -128,6 +158,7 @@ void processCampaigns_whenDebitFails_shouldContinueProcessing() { campaign.setRecipients("255700112233"); SmsTemplate template = new SmsTemplate(); template.setContent("Hello"); + template.setStatus(SmsTemplateStatus.ACTIVE); campaign.setTemplate(template); SmsConnector connector = new SmsConnector(); campaign.setConnector(connector); 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 9fb55e4..2362029 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 @@ -5,6 +5,7 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; @@ -143,10 +144,27 @@ void queueTemplatedSms_shouldThrowWhenTemplateNotFound() { assertTrue(ex.getReason().contains("Template not found or you don't have access to it")); } + @Test + void queueTemplatedSms_shouldThrowWhenTemplateIsInactive() { + SmsTemplate template = new SmsTemplate(); + template.setContent("Hello {{name}}"); + template.setStatus(SmsTemplateStatus.INACTIVE); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueTemplatedSms(validPlaceholders, "testuser")); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("Template is not active")); + } + @Test void queueTemplatedSms_shouldThrowWhenConnectorNotFound() { SmsTemplate template = new SmsTemplate(); template.setContent("Hello {{name}}"); + template.setStatus(SmsTemplateStatus.ACTIVE); when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); @@ -164,6 +182,7 @@ void queueTemplatedSms_shouldThrowWhenConnectorNotFound() { void queueTemplatedSms_shouldQueueSmsSuccessfully() { SmsTemplate template = new SmsTemplate(); template.setContent("Hello {{name}}"); + template.setStatus(SmsTemplateStatus.ACTIVE); SmsConnector connector = new SmsConnector(); connector.setProvider("Twilio"); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignServiceTest.java new file mode 100644 index 0000000..67650af --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsCampaignServiceTest.java @@ -0,0 +1,80 @@ +package com.flexcodelabs.flextuma.modules.sms.services; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsCampaign; +import com.flexcodelabs.flextuma.core.entities.sms.SmsTemplate; +import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; +import com.flexcodelabs.flextuma.core.repositories.SmsCampaignRepository; +import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; +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.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SmsCampaignServiceTest { + + @Mock + private SmsCampaignRepository repository; + + @Mock + private SmsTemplateRepository templateRepository; + + @InjectMocks + private SmsCampaignService smsCampaignService; + + @Test + void onPreSave_shouldRejectInactiveTemplate() { + UUID templateId = UUID.randomUUID(); + SmsTemplate templateStub = new SmsTemplate(); + templateStub.setId(templateId); + + SmsTemplate persistedTemplate = new SmsTemplate(); + persistedTemplate.setId(templateId); + persistedTemplate.setStatus(SmsTemplateStatus.INACTIVE); + + when(templateRepository.findById(templateId)).thenReturn(Optional.of(persistedTemplate)); + + SmsCampaign campaign = new SmsCampaign(); + campaign.setTemplate(templateStub); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> smsCampaignService.onPreSave(campaign)); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + } + + @Test + void onPreSave_shouldAllowActiveTemplate() { + UUID templateId = UUID.randomUUID(); + SmsTemplate templateStub = new SmsTemplate(); + templateStub.setId(templateId); + + SmsTemplate persistedTemplate = new SmsTemplate(); + persistedTemplate.setId(templateId); + persistedTemplate.setStatus(SmsTemplateStatus.ACTIVE); + + when(templateRepository.findById(templateId)).thenReturn(Optional.of(persistedTemplate)); + + SmsCampaign campaign = new SmsCampaign(); + campaign.setTemplate(templateStub); + + smsCampaignService.onPreSave(campaign); + } + + @Test + void onPreSave_shouldAllowMissingTemplate() { + SmsCampaign campaign = new SmsCampaign(); + + smsCampaignService.onPreSave(campaign); + } +}