diff --git a/build.gradle b/build.gradle index c09fe96..3e3a448 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.68' +version = '0.0.69' description = 'Flextuma App' java { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java index c9d2e93..13af36e 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java @@ -157,6 +157,12 @@ private void applyStatus(SmsLog logEntry, String status, Map raw } private boolean isDeliveryRegression(SmsLogStatus current, SmsLogStatus next) { + // List.of(...)'s indexOf() throws NPE on a null argument (unlike ArrayList's, which just + // returns -1) -- and current is null for a log whose status hasn't been set yet, so this + // must short-circuit before reaching DELIVERY_PROGRESSION.indexOf(current) below. + if (current == null) { + return false; + } int currentIndex = DELIVERY_PROGRESSION.indexOf(current); int nextIndex = DELIVERY_PROGRESSION.indexOf(next); return currentIndex >= 0 && nextIndex >= 0 && nextIndex < currentIndex; diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java index ae1081e..4a39a4c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageService.java @@ -3,11 +3,15 @@ import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.auth.Organisation; import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; import com.flexcodelabs.flextuma.core.security.SecurityUtils; import com.flexcodelabs.flextuma.core.services.BaseService; +import com.flexcodelabs.flextuma.modules.sms.services.SmsLogService; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppConversationDTO; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO; import lombok.RequiredArgsConstructor; @@ -21,6 +25,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -34,6 +39,7 @@ public class WhatsAppInboxMessageService extends BaseService listConversations(int page, int pageS Map conversations = new LinkedHashMap<>(); Map unreadCounts = new LinkedHashMap<>(); + Map configsByConnectorId = new LinkedHashMap<>(); for (WhatsAppInboxMessage message : recent) { String key = message.getConfig().getId() + ":" + message.getFromNumber(); unreadCounts.merge(key, message.getReadAt() == null ? 1L : 0L, Long::sum); @@ -144,9 +151,19 @@ public Pagination listConversations(int page, int pageS .lastMessageType(message.getMessageType()) .lastMessageAt(message.getReceivedAt()) .build()); + if (message.getConfig().getConnector() != null) { + configsByConnectorId.putIfAbsent(message.getConfig().getConnector().getId(), message.getConfig()); + } } + applyOutboundActivity(conversations, configsByConnectorId); + + // Folding outbound activity in above can both reorder an existing conversation (a reply + // makes it more recent than one that hasn't been touched since) and append brand new + // ones (outbound-only, no inbound message yet) at the end of insertion order -- so the + // map's iteration order no longer reflects recency and must be re-sorted explicitly. List all = conversations.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.comparing(WhatsAppConversationDTO::lastMessageAt).reversed())) .map(entry -> { WhatsAppConversationDTO summary = entry.getValue(); return WhatsAppConversationDTO.builder() @@ -174,4 +191,45 @@ public Pagination listConversations(int page, int pageS .data(pageData) .build(); } + + /** Folds recent outbound WhatsApp sends into the conversation summaries built from inbound + * messages above. Outbound sends are logged as {@code SmsLog} rows (there's no outbound + * counterpart to {@code WhatsAppInboxMessage}), so without this a reply sent after the + * contact's last inbound message never updates the list's preview text or ordering. An + * outbound row is matched to a conversation via the WhatsApp connector shared by + * {@code SmsLog.connector} and {@code WhatsAppWebhookConfig.connector}; a config with no + * connector explicitly linked can't be attributed this way and is skipped (same fallback + * gap {@code WhatsAppMediaService.resolveConnector} already accepts). */ + private void applyOutboundActivity(Map conversations, + Map configsByConnectorId) { + if (configsByConnectorId.isEmpty()) { + return; + } + + List recentOutbound = smsLogService.findAllPaginated( + PageRequest.of(0, CONVERSATION_SCAN_LIMIT, Sort.by(Sort.Direction.DESC, "created")), + List.of("connector.provider:eq:WHATSAPP"), null, "AND").getData(); + + for (SmsLog log : recentOutbound) { + SmsConnector connector = log.getConnector(); + WhatsAppWebhookConfig config = connector != null ? configsByConnectorId.get(connector.getId()) : null; + if (config == null) { + continue; + } + String key = config.getId() + ":" + log.getRecipient(); + WhatsAppConversationDTO existing = conversations.get(key); + if (existing != null && !log.getCreated().isAfter(existing.lastMessageAt())) { + continue; + } + conversations.put(key, WhatsAppConversationDTO.builder() + .configId(config.getId()) + .phoneNumberId(config.getPhoneNumberId()) + .fromNumber(log.getRecipient()) + .contactName(existing != null ? existing.contactName() : null) + .lastMessageContent(log.getContent()) + .lastMessageType("text") + .lastMessageAt(log.getCreated()) + .build()); + } + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java index e38f88d..ef03e01 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppInboxMessageServiceTest.java @@ -3,10 +3,13 @@ import com.flexcodelabs.flextuma.core.dtos.Pagination; import com.flexcodelabs.flextuma.core.entities.auth.Organisation; import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; +import com.flexcodelabs.flextuma.modules.sms.services.SmsLogService; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppConversationDTO; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTenantStorageUsageDTO; import org.junit.jupiter.api.AfterEach; @@ -50,6 +53,9 @@ class WhatsAppInboxMessageServiceTest { @Mock private CurrentUserResolver currentUserResolver; + @Mock + private SmsLogService smsLogService; + @InjectMocks private WhatsAppInboxMessageService service; @@ -113,6 +119,41 @@ void listConversations_shouldGroupByConfigAndFromNumber_andCountUnread() { assertEquals(0, benConversation.unreadCount()); } + @Test + void listConversations_shouldReflectOutboundReply_sentAfterLastInboundMessage() { + SmsConnector connector = new SmsConnector(); + connector.setId(UUID.randomUUID()); + + WhatsAppWebhookConfig config = new WhatsAppWebhookConfig(); + config.setId(UUID.randomUUID()); + config.setPhoneNumberId("104725069208652"); + config.setConnector(connector); + + LocalDateTime inboundAt = LocalDateTime.now().minusHours(2); + WhatsAppInboxMessage inbound = message(config, "255655392445", "whatup", inboundAt, false); + + when(repository.findAll(any(Specification.class), any(Pageable.class))) + .thenReturn(new PageImpl<>(List.of(inbound))); + + SmsLog outboundReply = new SmsLog(); + outboundReply.setRecipient("255655392445"); + outboundReply.setContent("Testing....."); + outboundReply.setConnector(connector); + outboundReply.setCreated(inboundAt.plusHours(2)); + + when(smsLogService.findAllPaginated(any(Pageable.class), any(), any(), any())) + .thenReturn(Pagination.builder().data(List.of(outboundReply)).build()); + + Pagination result = service.listConversations(0, 25); + + WhatsAppConversationDTO conversation = result.getData().get(0); + assertEquals("Testing.....", conversation.lastMessageContent()); + assertEquals(inboundAt.plusHours(2), conversation.lastMessageAt()); + // The unread count still reflects the unread inbound message; the outbound reply itself + // isn't something the agent can leave "unread". + assertEquals(1, conversation.unreadCount()); + } + @Test void listConversations_shouldExposeTypeAndNullContent_whenMediaMessageHasNoCaption() { WhatsAppWebhookConfig config = new WhatsAppWebhookConfig(); @@ -155,6 +196,7 @@ void listConversations_shouldPaginate() { @Test void getMedia_shouldReturnBytesAndMimeType_whenMediaStored() { WhatsAppInboxMessage message = message(new WhatsAppWebhookConfig(), "255700000001", null, LocalDateTime.now(), false); + message.setMediaId("wamid.stored"); message.setMediaPath("stored-filename"); message.setMimeType("image/jpeg"); when(repository.findOne(any(Specification.class))).thenReturn(Optional.of(message)); @@ -177,9 +219,13 @@ void getMedia_shouldThrowNotFound_whenMessageHasNoMedia() { @Test void getMedia_shouldThrowNotFound_whenStoredFileIsMissing() { WhatsAppInboxMessage message = message(new WhatsAppWebhookConfig(), "255700000001", null, LocalDateTime.now(), false); + message.setMediaId("wamid.stored"); message.setMediaPath("stored-filename"); when(repository.findOne(any(Specification.class))).thenReturn(Optional.of(message)); when(mediaService.read("stored-filename")).thenReturn(Optional.empty()); + // getMedia() retries the download when the cached file is missing; stub it to also fail + // so this stays a true "media unavailable" case rather than exercising the retry's happy path. + when(mediaService.download(message.getConfig(), "wamid.stored")).thenReturn(Optional.empty()); assertThrows(ResponseStatusException.class, () -> service.getMedia(message.getId())); }