Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.68'
version = '0.0.69'
description = 'Flextuma App'

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ private void applyStatus(SmsLog logEntry, String status, Map<String, Object> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -34,6 +39,7 @@ public class WhatsAppInboxMessageService extends BaseService<WhatsAppInboxMessag
private final WhatsAppInboxMessageRepository repository;
private final WhatsAppMediaService mediaService;
private final CurrentUserResolver currentUserResolver;
private final SmsLogService smsLogService;

public record MediaContent(byte[] bytes, String mimeType) {}

Expand Down Expand Up @@ -132,6 +138,7 @@ public Pagination<WhatsAppConversationDTO> listConversations(int page, int pageS

Map<String, WhatsAppConversationDTO> conversations = new LinkedHashMap<>();
Map<String, Long> unreadCounts = new LinkedHashMap<>();
Map<UUID, WhatsAppWebhookConfig> configsByConnectorId = new LinkedHashMap<>();
for (WhatsAppInboxMessage message : recent) {
String key = message.getConfig().getId() + ":" + message.getFromNumber();
unreadCounts.merge(key, message.getReadAt() == null ? 1L : 0L, Long::sum);
Expand All @@ -144,9 +151,19 @@ public Pagination<WhatsAppConversationDTO> 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<WhatsAppConversationDTO> all = conversations.entrySet().stream()
.sorted(Map.Entry.comparingByValue(Comparator.comparing(WhatsAppConversationDTO::lastMessageAt).reversed()))
.map(entry -> {
WhatsAppConversationDTO summary = entry.getValue();
return WhatsAppConversationDTO.builder()
Expand Down Expand Up @@ -174,4 +191,45 @@ public Pagination<WhatsAppConversationDTO> 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<String, WhatsAppConversationDTO> conversations,
Map<UUID, WhatsAppWebhookConfig> configsByConnectorId) {
if (configsByConnectorId.isEmpty()) {
return;
}

List<SmsLog> 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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -50,6 +53,9 @@ class WhatsAppInboxMessageServiceTest {
@Mock
private CurrentUserResolver currentUserResolver;

@Mock
private SmsLogService smsLogService;

@InjectMocks
private WhatsAppInboxMessageService service;

Expand Down Expand Up @@ -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.<SmsLog>builder().data(List.of(outboundReply)).build());

Pagination<WhatsAppConversationDTO> 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();
Expand Down Expand Up @@ -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));
Expand All @@ -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()));
}
Expand Down