From 007e3da99b3813446e656c7d3f5795822c6ab2dd Mon Sep 17 00:00:00 2001 From: Bennett Date: Thu, 22 Oct 2026 07:44:37 +0300 Subject: [PATCH 1/2] release: Fix errors in syncing across numbers --- docs/whatsapp-templates.md | 12 + .../entities/whatsapp/WhatsAppTemplate.java | 7 + .../helpers/WhatsAppConnectorSettings.java | 29 ++ .../WhatsAppTemplateRepository.java | 8 +- .../services/NotificationService.java | 19 +- .../WhatsAppTemplateController.java | 11 +- .../dtos/WhatsAppTemplateSyncResultDTO.java | 17 ++ .../services/WhatsAppTemplateSyncService.java | 288 ++++++------------ .../services/meta/WhatsAppMetaService.java | 17 +- .../templates/WhatsAppTemplateFetcher.java | 49 +++ .../templates/WhatsAppTemplateMapper.java | 100 ++++++ .../services/NotificationServiceTest.java | 29 ++ .../WhatsAppTemplateSyncServiceTest.java | 215 +++++++++---- .../meta/WhatsAppMetaServiceTest.java | 2 +- 14 files changed, 515 insertions(+), 288 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/helpers/WhatsAppConnectorSettings.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppTemplateSyncResultDTO.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/templates/WhatsAppTemplateFetcher.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/templates/WhatsAppTemplateMapper.java diff --git a/docs/whatsapp-templates.md b/docs/whatsapp-templates.md index 7178bc2..b55552b 100644 --- a/docs/whatsapp-templates.md +++ b/docs/whatsapp-templates.md @@ -164,6 +164,18 @@ Expose it as `POST /api/whatsappTemplates/sync` (tenant-scoped: only syncs templates for the caller's own connector(s)), mirroring flexfarm-core's own `POST /api/whatsappTemplates/sync` endpoint one-for-one. +#### Multiple numbers and accounts (as implemented) + +Templates belong to a WhatsApp Business Account (WABA), not to a phone number. `POST /api/whatsappTemplates/sync` therefore: + +- groups the caller's active WhatsApp connectors by `extraSettings.businessAccountId` and fetches each account **once**, trying each number's token in turn if one has been revoked; +- stores the WABA on every template (`business_account_id`) and upserts by `(metaTemplateId, creator)`, so a template first synced through one number is updated, not duplicated, when synced through another; +- marks REMOVED only templates of the account being synced; +- reports each account separately in `{ synced, removed, accounts: [{ businessAccountId, phoneNumberIds, synced, removed, error }] }`. An expired token or a number with no WABA id produces an `error` for that account without stopping the others; +- accepts `?businessAccountId=` to sync a single account. + +When sending, the template is looked up in the sending number's WABA, so **any number in the account can send any of its templates**. Rows synced before the WABA column existed fall back to matching the connector they were synced through. + ### 3. Sending a template message Add a template-send path alongside the existing free-text path, without diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppTemplate.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppTemplate.java index 53005e4..5b80b00 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppTemplate.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppTemplate.java @@ -73,10 +73,17 @@ public class WhatsAppTemplate extends Owner { @Column(columnDefinition = "TEXT") private String placeholdersJson; + /** The connector the template was last synced through. Templates belong to the WhatsApp + * Business Account, not one number, so any number in {@link #businessAccountId} can send it. */ @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "connector", nullable = false) private SmsConnector connector; + /** WhatsApp Business Account (WABA) the template lives in. Null only on rows synced before + * this column existed; the next sync fills it in. */ + @Column(name = "business_account_id") + private String businessAccountId; + @Column(name = "last_synced_at") private LocalDateTime lastSyncedAt; } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/WhatsAppConnectorSettings.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/WhatsAppConnectorSettings.java new file mode 100644 index 0000000..2d3e4da --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/WhatsAppConnectorSettings.java @@ -0,0 +1,29 @@ +package com.flexcodelabs.flextuma.core.helpers; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; + +/** Reads WhatsApp-specific values from a connector's free-form extraSettings JSON. */ +public final class WhatsAppConnectorSettings { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private WhatsAppConnectorSettings() { + } + + /** The WhatsApp Business Account (WABA) id, or null when unset or unparsable. Templates + * belong to the WABA, so every number (connector) in one account shares them. */ + public static String businessAccountIdOf(SmsConnector connector) { + String settings = connector == null ? null : connector.getExtraSettings(); + if (settings == null || settings.isBlank()) { + return null; + } + try { + JsonNode value = MAPPER.readTree(settings).get("businessAccountId"); + return value == null || value.isNull() || value.asText().isBlank() ? null : value.asText(); + } catch (JsonProcessingException e) { + return null; + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java index 8d9fcaf..45dc43d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java @@ -14,7 +14,13 @@ public interface WhatsAppTemplateRepository extends BaseRepository, JpaSpecificationExecutor { - List findByConnectorAndCreatedBy(SmsConnector connector, User createdBy); + /** Every template the user has, across all WhatsApp Business Accounts (metaTemplateId is unique + * per creator), so a sync can update rows first synced through another number. */ + List findByCreatedBy(User createdBy); + + /** Send-time lookup: any number in the account can send the account's templates. */ + Optional findFirstByNameAndLanguageAndBusinessAccountIdAndCreatedBy(String name, String language, + String businessAccountId, User createdBy); Optional findByNameAndLanguageAndConnectorAndCreatedBy(String name, String language, SmsConnector connector, User createdBy); 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 559d96e..c233882 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.entities.whatsapp.WhatsAppTemplate; +import com.flexcodelabs.flextuma.core.helpers.WhatsAppConnectorSettings; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; @@ -122,12 +123,10 @@ public SmsLog queueWhatsAppTemplate(Map payload, String username SmsConnector connector = getConnector(currentUser, "WHATSAPP", stringOrNull(payload.get("connectorId"))); - WhatsAppTemplate template = whatsAppTemplateRepository - .findByNameAndLanguageAndConnectorAndCreatedBy(templateName, templateLanguage, connector, - connector.getCreatedBy()) + WhatsAppTemplate template = findTemplateForConnector(templateName, templateLanguage, connector) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No synced WhatsApp template found for [" + templateName + "/" + templateLanguage - + "] on this connector")); + + "] in this number's WhatsApp Business Account")); if (!WhatsAppTemplate.STATUS_APPROVED.equalsIgnoreCase(template.getStatus())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Template [" + templateName + "] is not approved (status: " + template.getStatus() + ")"); @@ -168,6 +167,18 @@ public SmsLog queueWhatsAppTemplate(Map payload, String username return savedLog; } + /** Templates belong to the WhatsApp Business Account, so any number in the account can send + * them; rows synced before business_account_id existed are still matched by connector. */ + private Optional findTemplateForConnector(String name, String language, SmsConnector connector) { + String businessAccountId = WhatsAppConnectorSettings.businessAccountIdOf(connector); + Optional inAccount = businessAccountId == null ? Optional.empty() + : whatsAppTemplateRepository.findFirstByNameAndLanguageAndBusinessAccountIdAndCreatedBy(name, + language, businessAccountId, connector.getCreatedBy()); + return inAccount.isPresent() ? inAccount + : whatsAppTemplateRepository.findByNameAndLanguageAndConnectorAndCreatedBy(name, language, + connector, connector.getCreatedBy()); + } + /** Bills a WhatsApp template send on a system connector. Unlike processAndSaveSms's text path * (which debits before an async send even attempts), this runs only once Meta has accepted * the template, so a tenant is never charged for a rejected send. */ diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppTemplateController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppTemplateController.java index 1373a89..6051ed3 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppTemplateController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppTemplateController.java @@ -2,14 +2,15 @@ import com.flexcodelabs.flextuma.core.controllers.BaseController; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTemplateSyncResultDTO; import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppTemplateService; import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppTemplateSyncService; import org.springframework.http.ResponseEntity; 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 java.util.List; @RestController @RequestMapping("/api/" + WhatsAppTemplate.PLURAL) @@ -21,10 +22,10 @@ public WhatsAppTemplateController(WhatsAppTemplateService service, WhatsAppTempl this.syncService = syncService; } - /** Pulls the caller's own WhatsApp connector(s) templates from Meta's Graph API and upserts - * them, marking any template Meta no longer returns as REMOVED. */ + /** Pulls templates from Meta for every WhatsApp Business Account the caller has a number in + * (or just {@code businessAccountId}), marking any Meta no longer returns as REMOVED. */ @PostMapping("/sync") - public ResponseEntity> sync() { - return ResponseEntity.ok(syncService.syncForCurrentUser()); + public ResponseEntity sync(@RequestParam(required = false) String businessAccountId) { + return ResponseEntity.ok(syncService.syncForCurrentUser(businessAccountId)); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppTemplateSyncResultDTO.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppTemplateSyncResultDTO.java new file mode 100644 index 0000000..7296bb1 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppTemplateSyncResultDTO.java @@ -0,0 +1,17 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.dtos; + +import java.util.List; + +/** + * Outcome of syncing templates, one entry per WhatsApp Business Account. A failing account (an + * expired token, a missing WABA id) is reported in {@code error} without stopping the others. + */ +public record WhatsAppTemplateSyncResultDTO(int synced, int removed, List accounts) { + + /** + * @param businessAccountId null for numbers with no WABA id set + * @param phoneNumberIds the numbers (connector sender ids) in this account + */ + public record Account(String businessAccountId, List phoneNumberIds, int synced, int removed, String error) { + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncService.java index 9df123b..52d37ec 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncService.java @@ -1,262 +1,154 @@ package com.flexcodelabs.flextuma.modules.whatsapp.services; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.helpers.WhatsAppConnectorSettings; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTemplateSyncResultDTO; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTemplateSyncResultDTO.Account; +import com.flexcodelabs.flextuma.modules.whatsapp.services.meta.MetaGraphException; +import com.flexcodelabs.flextuma.modules.whatsapp.services.templates.WhatsAppTemplateFetcher; +import com.flexcodelabs.flextuma.modules.whatsapp.services.templates.WhatsAppTemplateMapper; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; -import java.util.TreeSet; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.UUID; +import java.util.function.Function; import java.util.stream.Collectors; /** * Pulls Meta-approved WhatsApp Business templates into {@link WhatsAppTemplate} rows. Templates - * are synced, never hand-authored -- see docs/whatsapp-templates.md "Template sync". + * belong to a WhatsApp Business Account (WABA), not to one phone number, so each account is synced + * once however many numbers it has, and any of its numbers can then send them. Templates are + * synced, never hand-authored -- see docs/whatsapp-templates.md "Template sync". */ @Slf4j @Service @RequiredArgsConstructor public class WhatsAppTemplateSyncService { private static final String WHATSAPP_PROVIDER = "WHATSAPP"; - private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{(\\d+)}}"); + /** Grouping key for numbers with no WABA id; such numbers can't be synced. */ + private static final String NO_ACCOUNT = ""; private final WhatsAppTemplateRepository repository; private final SmsConnectorRepository connectorRepository; private final CurrentUserResolver currentUserResolver; - private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; + private final WhatsAppTemplateFetcher fetcher; + private final WhatsAppTemplateMapper mapper; - /** Syncs every active WHATSAPP connector the caller owns -- a tenant may have more than one - * (e.g. multiple WABAs), so this isn't limited to a single connector. */ + private record Fetched(SmsConnector connector, List> templates) { + } + + /** Syncs every account the caller has an active WhatsApp number in, or only {@code businessAccountId}. */ @Transactional - public List syncForCurrentUser() { + public WhatsAppTemplateSyncResultDTO syncForCurrentUser(String businessAccountId) { User currentUser = currentUserResolver.getCurrentUser() .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated")); - - List connectors = connectorRepository - .findAllByCreatedByAndProviderAndActiveTrue(currentUser, WHATSAPP_PROVIDER); + List connectors = connectorRepository.findAllByCreatedByAndProviderAndActiveTrue(currentUser, WHATSAPP_PROVIDER); if (connectors.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "No active WhatsApp connector found"); } - - List synced = new ArrayList<>(); - for (SmsConnector connector : connectors) { - synced.addAll(sync(connector)); - } - return synced; + Map existing = repository.findByCreatedBy(currentUser).stream() + .collect(Collectors.toMap(WhatsAppTemplate::getMetaTemplateId, Function.identity(), (a, b) -> a, LinkedHashMap::new)); + List accounts = byAccount(connectors).entrySet().stream() + .filter(account -> businessAccountId == null || account.getKey().equals(businessAccountId)) + .map(account -> syncAccount(account.getKey(), account.getValue(), existing)) + .toList(); + if (accounts.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No active WhatsApp number in account " + businessAccountId); + } + return new WhatsAppTemplateSyncResultDTO(accounts.stream().mapToInt(Account::synced).sum(), + accounts.stream().mapToInt(Account::removed).sum(), accounts); } - @Transactional - public List sync(SmsConnector connector) { - String businessAccountId = extractBusinessAccountId(connector); - if (businessAccountId == null || businessAccountId.isBlank()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, - "Connector [" + connector.getId() - + "] has no businessAccountId configured in extraSettings. Edit the connector and set" - + " Extra settings to {\"businessAccountId\": \"\"}"); - } - - List> fetched = fetchAllTemplates(connector, businessAccountId); - - Map existingByMetaId = repository - .findByConnectorAndCreatedBy(connector, connector.getCreatedBy()).stream() - .collect(Collectors.toMap(WhatsAppTemplate::getMetaTemplateId, t -> t, (a, b) -> a)); - - Set seenMetaIds = new HashSet<>(); - List upserted = new ArrayList<>(); - for (Map raw : fetched) { - Object idObj = raw.get("id"); - if (idObj == null) { - continue; - } - String metaTemplateId = idObj.toString(); - seenMetaIds.add(metaTemplateId); - WhatsAppTemplate template = existingByMetaId.getOrDefault(metaTemplateId, new WhatsAppTemplate()); - applyMetaTemplate(template, raw, connector); - upserted.add(repository.save(template)); - } - - // A template Meta no longer returns is marked REMOVED rather than deleted, so any - // existing send configuration referencing it fails validation instead of dangling. - for (WhatsAppTemplate existing : existingByMetaId.values()) { - if (!seenMetaIds.contains(existing.getMetaTemplateId()) - && !WhatsAppTemplate.STATUS_REMOVED.equals(existing.getStatus())) { - existing.setStatus(WhatsAppTemplate.STATUS_REMOVED); - existing.setLastSyncedAt(LocalDateTime.now()); - repository.save(existing); - } - } - - log.info("Synced {} WhatsApp template(s) for connector [{}]", upserted.size(), connector.getId()); - return upserted; + private static Map> byAccount(List connectors) { + return connectors.stream().collect(Collectors.groupingBy( + connector -> Objects.requireNonNullElse(WhatsAppConnectorSettings.businessAccountIdOf(connector), NO_ACCOUNT), + LinkedHashMap::new, Collectors.toList())); } - @SuppressWarnings("unchecked") - private String extractBusinessAccountId(SmsConnector connector) { - String extraSettings = connector.getExtraSettings(); - if (extraSettings == null || extraSettings.isBlank()) { - return null; + private Account syncAccount(String businessAccountId, List connectors, Map existing) { + List phoneNumberIds = connectors.stream().map(SmsConnector::getSenderId).toList(); + if (NO_ACCOUNT.equals(businessAccountId)) { + return new Account(null, phoneNumberIds, 0, 0, + "Add the WhatsApp Business Account ID to these numbers to sync their templates."); } try { - Map settings = objectMapper.readValue(extraSettings, Map.class); - Object value = settings.get("businessAccountId"); - return value == null ? null : value.toString(); - } catch (Exception e) { - log.warn("Connector [{}] has unparsable extraSettings: {}", connector.getId(), e.getMessage()); - return null; - } - } - - private List> fetchAllTemplates(SmsConnector connector, String businessAccountId) { - List> all = new ArrayList<>(); - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(connector.getKey()); - - String url = connector.getUrl().replaceAll("/+$", "") + "/" + businessAccountId - + "/message_templates?limit=100"; - int guard = 0; - while (url != null && guard++ < 100) { - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, - new HttpEntity<>(headers), Map.class); - Map body = objectMapper.convertValue(response.getBody(), new TypeReference<>() { - }); - all.addAll(extractData(body)); - url = nextPageUrl(body); + Fetched fetched = fetchWithAnyNumber(businessAccountId, connectors); + Set seen = upsert(fetched, businessAccountId, existing); + int removed = markRemoved(businessAccountId, connectors, existing, seen); + log.info("Synced {} WhatsApp template(s) for account [{}]", seen.size(), businessAccountId); + return new Account(businessAccountId, phoneNumberIds, seen.size(), removed, null); + } catch (MetaGraphException e) { + return new Account(businessAccountId, phoneNumberIds, 0, 0, e.getMessage()); } - return all; } - @SuppressWarnings("unchecked") - private List> extractData(Map body) { - if (body == null || !(body.get("data") instanceof List list)) { - return List.of(); - } - List> data = new ArrayList<>(); - for (Object item : list) { - if (item instanceof Map map) { - data.add((Map) map); + /** Numbers in one account usually share a token, but if one number's token was revoked another's may still work. */ + private Fetched fetchWithAnyNumber(String businessAccountId, List connectors) { + MetaGraphException last = null; + for (SmsConnector connector : connectors) { + try { + return new Fetched(connector, fetcher.fetchAll(connector, businessAccountId)); + } catch (MetaGraphException e) { + last = e; } } - return data; - } - - private String nextPageUrl(Map body) { - if (body == null || !(body.get("paging") instanceof Map paging)) { - return null; - } - Object next = paging.get("next"); - return next == null ? null : next.toString(); + throw Objects.requireNonNull(last); } - private void applyMetaTemplate(WhatsAppTemplate template, Map raw, SmsConnector connector) { - template.setMetaTemplateId(stringOrNull(raw.get("id"))); - template.setName(stringOrNull(raw.get("name"))); - template.setCategory(stringOrNull(raw.get("category"))); - template.setLanguage(stringOrNull(raw.get("language"))); - template.setStatus(stringOrNull(raw.get("status"))); - template.setConnector(connector); - if (template.getCreatedBy() == null) { - template.setCreatedBy(connector.getCreatedBy()); - } - - Object components = raw.get("components"); - try { - template.setComponentsJson(objectMapper.writeValueAsString(components != null ? components : List.of())); - template.setPlaceholdersJson(objectMapper.writeValueAsString(derivePlaceholders(components))); - } catch (Exception e) { - log.warn("Failed to serialize components for template [{}]: {}", template.getMetaTemplateId(), - e.getMessage()); - } - template.setLastSyncedAt(LocalDateTime.now()); - } - - /** Derives a flat {component, type, position} placeholder list from each component's - * {{n}} count/format: text placeholders from HEADER/BODY text, a single media placeholder - * for a non-TEXT header format, and one per dynamic-URL button. */ - @SuppressWarnings("unchecked") - private List> derivePlaceholders(Object componentsObj) { - List> placeholders = new ArrayList<>(); - if (!(componentsObj instanceof List components)) { - return placeholders; - } - for (Object c : components) { - if (!(c instanceof Map component)) { - continue; - } - String type = stringOrNull(component.get("type")); - if (type == null) { - continue; - } - if ("BUTTONS".equalsIgnoreCase(type)) { - placeholders.addAll(buttonPlaceholders((Map) component)); + private Set upsert(Fetched fetched, String businessAccountId, Map existing) { + Set seen = new HashSet<>(); + for (Map raw : fetched.templates()) { + String metaTemplateId = WhatsAppPayloads.text(raw.get("id")).orElse(null); + if (metaTemplateId == null) { continue; } - String format = stringOrNull(component.get("format")); - if (format != null && !"TEXT".equalsIgnoreCase(format)) { - placeholders.add(Map.of("component", type, "type", format.toLowerCase(), "position", 1)); - continue; - } - String text = stringOrNull(component.get("text")); - if (text == null) { - continue; - } - for (Integer position : placeholderPositions(text)) { - placeholders.add(Map.of("component", type, "type", "text", "position", position)); - } + seen.add(metaTemplateId); + WhatsAppTemplate template = existing.getOrDefault(metaTemplateId, new WhatsAppTemplate()); + mapper.apply(template, raw, fetched.connector(), businessAccountId); + existing.put(metaTemplateId, repository.save(template)); } - return placeholders; + return seen; } - @SuppressWarnings("unchecked") - private List> buttonPlaceholders(Map buttonsComponent) { - List> placeholders = new ArrayList<>(); - if (!(buttonsComponent.get("buttons") instanceof List buttons)) { - return placeholders; - } - for (int i = 0; i < buttons.size(); i++) { - if (!(buttons.get(i) instanceof Map button)) { - continue; - } - String buttonType = stringOrNull(button.get("type")); - if ("URL".equalsIgnoreCase(buttonType) && button.get("example") != null) { - placeholders.add(Map.of("component", "BUTTON", "subType", "url", "index", i, "type", "text")); - } - } - return placeholders; + /** A template Meta no longer returns is marked REMOVED rather than deleted, so any send + * configuration referencing it fails validation instead of dangling. */ + private int markRemoved(String businessAccountId, List connectors, Map existing, Set seen) { + Set connectorIds = connectors.stream().map(SmsConnector::getId).collect(Collectors.toSet()); + List gone = existing.values().stream() + .filter(template -> belongsTo(template, businessAccountId, connectorIds)) + .filter(template -> !seen.contains(template.getMetaTemplateId())) + .filter(template -> !WhatsAppTemplate.STATUS_REMOVED.equals(template.getStatus())) + .toList(); + gone.forEach(template -> { + template.setStatus(WhatsAppTemplate.STATUS_REMOVED); + template.setLastSyncedAt(LocalDateTime.now()); + repository.save(template); + }); + return gone.size(); } - private Set placeholderPositions(String text) { - Set positions = new TreeSet<>(); - Matcher matcher = PLACEHOLDER_PATTERN.matcher(text); - while (matcher.find()) { - positions.add(Integer.parseInt(matcher.group(1))); + /** Rows synced before business_account_id existed are matched by the connector they came through. */ + private static boolean belongsTo(WhatsAppTemplate template, String businessAccountId, Set connectorIds) { + if (template.getBusinessAccountId() != null) { + return template.getBusinessAccountId().equals(businessAccountId); } - return positions; - } - - private String stringOrNull(Object value) { - return value == null ? null : value.toString(); + return template.getConnector() != null && connectorIds.contains(template.getConnector().getId()); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaService.java index bf4e708..0f6d8e1 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaService.java @@ -1,10 +1,9 @@ package com.flexcodelabs.flextuma.modules.whatsapp.services.meta; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.helpers.WhatsAppConnectorSettings; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckRequest; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckResult; @@ -31,7 +30,6 @@ public class WhatsAppMetaService { private final MetaGraphClient graph; private final SmsConnectorRepository connectorRepository; private final CurrentUserResolver currentUserResolver; - private final ObjectMapper objectMapper; record Credentials(String baseUrl, String token, String businessAccountId, String phoneNumberId) { } @@ -118,7 +116,7 @@ private Credentials fromConnector(WhatsAppMetaCheckRequest request) { .filter(found -> "WHATSAPP".equalsIgnoreCase(found.getProvider()) && isOwnedByCurrentUser(found)) .orElseThrow(() -> new MetaGraphException("NOT_FOUND", "WhatsApp connector not found.")); boolean tokenOnly = Boolean.TRUE.equals(request.tokenOnly()); - String waba = Optional.ofNullable(blankToNull(request.businessAccountId())).orElseGet(() -> tokenOnly ? null : businessAccountIdOf(connector)); + String waba = Optional.ofNullable(blankToNull(request.businessAccountId())).orElseGet(() -> tokenOnly ? null : WhatsAppConnectorSettings.businessAccountIdOf(connector)); String phone = Optional.ofNullable(blankToNull(request.phoneNumberId())).orElse(tokenOnly ? null : connector.getSenderId()); return new Credentials(MetaGraphClient.validatedBaseUrl(connector.getUrl()), connector.getKey(), metaId(waba, "WhatsApp Business Account ID"), metaId(phone, "Phone number ID")); @@ -129,17 +127,6 @@ private boolean isOwnedByCurrentUser(SmsConnector connector) { return user.isPresent() && connector.getCreatedBy() != null && connector.getCreatedBy().getId().equals(user.get().getId()); } - private String businessAccountIdOf(SmsConnector connector) { - if (connector.getExtraSettings() == null || connector.getExtraSettings().isBlank()) { - return null; - } - try { - return WhatsAppPayloads.textOr(objectMapper.readValue(connector.getExtraSettings(), Map.class).get("businessAccountId"), null); - } catch (JsonProcessingException e) { - return null; - } - } - /** IDs go into the request path, so anything but digits is rejected rather than escaped. */ private static String metaId(String value, String label) { String trimmed = blankToNull(value); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/templates/WhatsAppTemplateFetcher.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/templates/WhatsAppTemplateFetcher.java new file mode 100644 index 0000000..d5d32c4 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/templates/WhatsAppTemplateFetcher.java @@ -0,0 +1,49 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.templates; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.modules.whatsapp.services.meta.MetaGraphClient; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Reads every template of a WhatsApp Business Account from Meta, following cursor pagination. */ +@Component +@RequiredArgsConstructor +public class WhatsAppTemplateFetcher { + private static final int MAX_PAGES = 100; + + private final MetaGraphClient graph; + + /** Throws MetaGraphException with a user-facing message when Meta rejects the request. */ + public List> fetchAll(SmsConnector connector, String businessAccountId) { + String basePath = "/" + businessAccountId + "/message_templates?limit=100"; + List> all = new ArrayList<>(); + String after = null; + for (int page = 0; page < MAX_PAGES; page++) { + Map body = graph.get(connector.getUrl(), connector.getKey(), after == null ? basePath : basePath + "&after=" + after); + all.addAll(WhatsAppPayloads.mapList(body, "data")); + after = nextCursor(body); + if (after == null) { + break; + } + } + return all; + } + + /** Meta only includes paging.next when another page exists; its cursor is reused against the + * validated base URL rather than following the absolute URL Meta returns. */ + private static String nextCursor(Map body) { + Map paging = WhatsAppPayloads.nestedMap(body, "paging"); + if (paging.get("next") == null) { + return null; + } + return WhatsAppPayloads.text(WhatsAppPayloads.nestedMap(paging, "cursors").get("after")) + .map(cursor -> URLEncoder.encode(cursor, StandardCharsets.UTF_8)).orElse(null); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/templates/WhatsAppTemplateMapper.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/templates/WhatsAppTemplateMapper.java new file mode 100644 index 0000000..62f0715 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/templates/WhatsAppTemplateMapper.java @@ -0,0 +1,100 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.templates; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.IntStream; + +/** Copies one template from Meta's message_templates response onto a {@link WhatsAppTemplate}. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WhatsAppTemplateMapper { + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{(\\d+)}}"); + private static final String COMPONENT = "component"; + private static final String TYPE = "type"; + private static final String TEXT = "text"; + + private final ObjectMapper objectMapper; + + public void apply(WhatsAppTemplate template, Map raw, SmsConnector connector, String businessAccountId) { + template.setMetaTemplateId(stringOrNull(raw.get("id"))); + template.setName(stringOrNull(raw.get("name"))); + template.setCategory(stringOrNull(raw.get("category"))); + template.setLanguage(stringOrNull(raw.get("language"))); + template.setStatus(stringOrNull(raw.get("status"))); + template.setConnector(connector); + template.setBusinessAccountId(businessAccountId); + if (template.getCreatedBy() == null) { + template.setCreatedBy(connector.getCreatedBy()); + } + List> components = WhatsAppPayloads.mapList(raw, "components"); + try { + template.setComponentsJson(objectMapper.writeValueAsString(components)); + template.setPlaceholdersJson(objectMapper.writeValueAsString(derivePlaceholders(components))); + } catch (JsonProcessingException e) { + log.warn("Failed to serialize components for template [{}]: {}", template.getMetaTemplateId(), e.getMessage()); + } + template.setLastSyncedAt(LocalDateTime.now()); + } + + /** A flat {component, type, position} list: text placeholders from HEADER/BODY text, one media + * placeholder for a non-TEXT header format, and one per dynamic-URL button. */ + List> derivePlaceholders(List> components) { + List> placeholders = new ArrayList<>(); + components.forEach(component -> placeholders.addAll(placeholdersOf(component))); + return placeholders; + } + + private List> placeholdersOf(Map component) { + String type = stringOrNull(component.get(TYPE)); + if (type == null) { + return List.of(); + } + if ("BUTTONS".equalsIgnoreCase(type)) { + return buttonPlaceholders(component); + } + String format = stringOrNull(component.get("format")); + if (format != null && !"TEXT".equalsIgnoreCase(format)) { + return List.of(Map.of(COMPONENT, type, TYPE, format.toLowerCase(), "position", 1)); + } + String text = stringOrNull(component.get(TEXT)); + return text == null ? List.of() : placeholderPositions(text).stream() + .map(position -> Map.of(COMPONENT, type, TYPE, TEXT, "position", position)).toList(); + } + + private List> buttonPlaceholders(Map buttonsComponent) { + List> buttons = WhatsAppPayloads.mapList(buttonsComponent, "buttons"); + return IntStream.range(0, buttons.size()) + .filter(i -> "URL".equalsIgnoreCase(stringOrNull(buttons.get(i).get(TYPE))) && buttons.get(i).get("example") != null) + .mapToObj(i -> Map.of(COMPONENT, "BUTTON", "subType", "url", "index", i, TYPE, TEXT)) + .toList(); + } + + private Set placeholderPositions(String text) { + Set positions = new TreeSet<>(); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(text); + while (matcher.find()) { + positions.add(Integer.parseInt(matcher.group(1))); + } + return positions; + } + + private static String stringOrNull(Object value) { + return value == null ? null : value.toString(); + } +} 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 89a2ea8..81e6342 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 @@ -298,6 +298,35 @@ void queueWhatsAppTemplate_shouldRejectWhenNoSyncedTemplateMatches() { verifyNoInteractions(whatsAppSender); } + @Test + void queueWhatsAppTemplate_shouldSendFromAnyNumberInTheTemplatesAccount() { + SmsConnector secondNumber = new SmsConnector(); + secondNumber.setProvider("WHATSAPP"); + secondNumber.setSenderId("222"); + secondNumber.setExtraSettings("{\"businessAccountId\":\"999888777\"}"); + secondNumber.setCreatedBy(testUser); + + // Synced through another number of the same WhatsApp Business Account. + WhatsAppTemplate template = new WhatsAppTemplate(); + template.setStatus("APPROVED"); + template.setBusinessAccountId("999888777"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(connectorRepository.findPreferredActiveConnector(testUser, "WHATSAPP")) + .thenReturn(Optional.of(secondNumber)); + when(whatsAppTemplateRepository.findFirstByNameAndLanguageAndBusinessAccountIdAndCreatedBy("farm_alert", "en", + "999888777", testUser)).thenReturn(Optional.of(template)); + when(whatsAppSender.sendTemplate(eq(secondNumber), eq("+255700000000"), eq("farm_alert"), eq("en"), any())) + .thenReturn(SmsSendResult.success("accepted", "wamid.456", Map.of("messages", "ok"))); + when(logRepository.save(any(SmsLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + notificationService.queueWhatsAppTemplate(validTemplatePayload(), "testuser"); + + verify(logRepository).save(smsLogCaptor.capture()); + assertEquals(SmsLogStatus.SENT, smsLogCaptor.getValue().getStatus()); + verify(whatsAppTemplateRepository, never()).findByNameAndLanguageAndConnectorAndCreatedBy(any(), any(), any(), any()); + } + @Test void queueWhatsAppTemplate_shouldRejectWhenTemplateNotApproved() { SmsConnector connector = new SmsConnector(); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncServiceTest.java index 307de41..cc2a122 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncServiceTest.java @@ -7,6 +7,10 @@ import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppTemplateSyncResultDTO; +import com.flexcodelabs.flextuma.modules.whatsapp.services.meta.MetaGraphClient; +import com.flexcodelabs.flextuma.modules.whatsapp.services.templates.WhatsAppTemplateFetcher; +import com.flexcodelabs.flextuma.modules.whatsapp.services.templates.WhatsAppTemplateMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,123 +18,206 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class WhatsAppTemplateSyncServiceTest { + private static final String GRAPH = "https://graph.facebook.com/v21.0"; + private static final String WABA = "999888777"; + private static final String OTHER_WABA = "555444333"; - @Mock - private WhatsAppTemplateRepository repository; - - @Mock - private SmsConnectorRepository connectorRepository; - - @Mock - private CurrentUserResolver currentUserResolver; - - @Mock - private RestTemplate restTemplate; - - private final ObjectMapper objectMapper = new ObjectMapper(); + @Mock private WhatsAppTemplateRepository repository; + @Mock private SmsConnectorRepository connectorRepository; + @Mock private CurrentUserResolver currentUserResolver; + @Mock private RestTemplate restTemplate; private WhatsAppTemplateSyncService service; - private User testUser; - private SmsConnector connector; @BeforeEach void setUp() { - service = new WhatsAppTemplateSyncService(repository, connectorRepository, currentUserResolver, restTemplate, - objectMapper); - + ObjectMapper objectMapper = new ObjectMapper(); + service = new WhatsAppTemplateSyncService(repository, connectorRepository, currentUserResolver, + new WhatsAppTemplateFetcher(new MetaGraphClient(restTemplate, objectMapper)), new WhatsAppTemplateMapper(objectMapper)); testUser = new User(); testUser.setId(UUID.randomUUID()); + lenient().when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(testUser)); + lenient().when(repository.save(any(WhatsAppTemplate.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } - connector = new SmsConnector(); + private SmsConnector connector(String senderId, String waba, String token) { + SmsConnector connector = new SmsConnector(); connector.setId(UUID.randomUUID()); connector.setProvider("WHATSAPP"); - connector.setUrl("https://graph.facebook.com/v21.0"); - connector.setKey("test-token"); - connector.setExtraSettings("{\"businessAccountId\":\"999888777\"}"); + connector.setUrl(GRAPH); + connector.setKey(token); + connector.setSenderId(senderId); + connector.setExtraSettings(waba == null ? null : "{\"businessAccountId\":\"" + waba + "\"}"); connector.setCreatedBy(testUser); + return connector; } - private Map metaResponse(List> templates) { - return Map.of("data", templates, "paging", Map.of()); + private void givenConnectors(SmsConnector... connectors) { + when(connectorRepository.findAllByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")).thenReturn(List.of(connectors)); + } + + private static Map template(String id, String name) { + return Map.of("id", id, "name", name, "category", "UTILITY", "language", "en", "status", "APPROVED", + "components", List.of(Map.of("type", "BODY", "text", "Feed is low, refill by {{1}}"))); + } + + private void metaReturns(String waba, Map body) { + when(restTemplate.exchange(eq(GRAPH + "/" + waba + "/message_templates?limit=100"), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(body)); + } + + private static HttpClientErrorException expiredToken() { + byte[] body = "{\"error\":{\"message\":\"Error validating access token\",\"code\":190}}".getBytes(StandardCharsets.UTF_8); + return HttpClientErrorException.create(HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, body, StandardCharsets.UTF_8); + } + + private List savedTemplates(int times) { + ArgumentCaptor saved = ArgumentCaptor.forClass(WhatsAppTemplate.class); + verify(repository, times(times)).save(saved.capture()); + return saved.getAllValues(); } @Test void syncForCurrentUser_shouldThrowWhenNoConnector() { - when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(testUser)); - when(connectorRepository.findAllByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) - .thenReturn(List.of()); + givenConnectors(); - ResponseStatusException ex = assertThrows(ResponseStatusException.class, service::syncForCurrentUser); + ResponseStatusException ex = assertThrows(ResponseStatusException.class, () -> service.syncForCurrentUser(null)); assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); } @Test - void syncForCurrentUser_shouldThrowWhenConnectorHasNoBusinessAccountId() { - connector.setExtraSettings(null); - when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(testUser)); - when(connectorRepository.findAllByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) - .thenReturn(List.of(connector)); + void sync_shouldReportNumbersWithoutAWabaId_withoutCallingMeta() { + givenConnectors(connector("111", null, "t")); - ResponseStatusException ex = assertThrows(ResponseStatusException.class, service::syncForCurrentUser); - assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); - assertTrue(ex.getReason().contains("businessAccountId")); + WhatsAppTemplateSyncResultDTO result = service.syncForCurrentUser(null); + + assertEquals(1, result.accounts().size()); + assertNull(result.accounts().get(0).businessAccountId()); + assertTrue(result.accounts().get(0).error().contains("WhatsApp Business Account ID")); + verify(restTemplate, never()).exchange(any(String.class), any(HttpMethod.class), any(HttpEntity.class), eq(Map.class)); } @Test - void sync_shouldUpsertNewTemplateAndMarkMissingOneRemoved() { - Map newTemplate = Map.of( - "id", "META_ID_NEW", - "name", "farm_alert", - "category", "UTILITY", - "language", "en", - "status", "APPROVED", - "components", List.of(Map.of("type", "BODY", "text", "Feed is low, refill by {{1}}"))); + void sync_shouldUpsertNewTemplatesAndMarkMissingOnesRemoved() { + SmsConnector number = connector("111", WABA, "t"); + givenConnectors(number); + metaReturns(WABA, Map.of("data", List.of(template("META_ID_NEW", "farm_alert")))); + WhatsAppTemplate stale = new WhatsAppTemplate(); + stale.setMetaTemplateId("META_ID_STALE"); + stale.setStatus("APPROVED"); + stale.setBusinessAccountId(WABA); + stale.setConnector(number); + when(repository.findByCreatedBy(testUser)).thenReturn(new ArrayList<>(List.of(stale))); + + WhatsAppTemplateSyncResultDTO result = service.syncForCurrentUser(null); + + assertEquals(1, result.synced()); + assertEquals(1, result.removed()); + List saved = savedTemplates(2); + WhatsAppTemplate created = saved.stream().filter(t -> "META_ID_NEW".equals(t.getMetaTemplateId())).findFirst().orElseThrow(); + assertEquals(WABA, created.getBusinessAccountId()); + assertTrue(created.getPlaceholdersJson().contains("\"position\":1")); + assertEquals(WhatsAppTemplate.STATUS_REMOVED, stale.getStatus()); + } - when(restTemplate.exchange(eq("https://graph.facebook.com/v21.0/999888777/message_templates?limit=100"), - eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) - .thenReturn(new ResponseEntity<>(metaResponse(List.of(newTemplate)), HttpStatus.OK)); + @Test + void sync_shouldFetchEachAccountOnce_andReuseRowsSyncedThroughAnotherNumber() { + SmsConnector first = connector("111", WABA, "t"); + SmsConnector second = connector("222", WABA, "t"); + givenConnectors(first, second); + metaReturns(WABA, Map.of("data", List.of(template("META_1", "farm_alert")))); + WhatsAppTemplate syncedViaSecond = new WhatsAppTemplate(); + syncedViaSecond.setMetaTemplateId("META_1"); + syncedViaSecond.setConnector(second); + when(repository.findByCreatedBy(testUser)).thenReturn(List.of(syncedViaSecond)); + + WhatsAppTemplateSyncResultDTO result = service.syncForCurrentUser(null); + + assertEquals(1, result.accounts().size()); + assertEquals(List.of("111", "222"), result.accounts().get(0).phoneNumberIds()); + verify(restTemplate, times(1)).exchange(any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class)); + assertSame(syncedViaSecond, savedTemplates(1).get(0)); + assertEquals(WABA, syncedViaSecond.getBusinessAccountId()); + } - WhatsAppTemplate staleTemplate = new WhatsAppTemplate(); - staleTemplate.setMetaTemplateId("META_ID_STALE"); - staleTemplate.setStatus("APPROVED"); - staleTemplate.setConnector(connector); - staleTemplate.setCreatedBy(testUser); + @Test + void sync_shouldFallBackToAnotherNumbersToken_whenOneIsRevoked() { + givenConnectors(connector("111", WABA, "revoked"), connector("222", WABA, "good")); + when(restTemplate.exchange(any(String.class), eq(HttpMethod.GET), argThat((HttpEntity e) -> e != null && "Bearer revoked".equals(e.getHeaders().getFirst(HttpHeaders.AUTHORIZATION))), eq(Map.class))) + .thenThrow(expiredToken()); + when(restTemplate.exchange(any(String.class), eq(HttpMethod.GET), argThat((HttpEntity e) -> e != null && "Bearer good".equals(e.getHeaders().getFirst(HttpHeaders.AUTHORIZATION))), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("data", List.of(template("META_1", "farm_alert"))))); + + WhatsAppTemplateSyncResultDTO result = service.syncForCurrentUser(null); - when(repository.findByConnectorAndCreatedBy(connector, testUser)).thenReturn(List.of(staleTemplate)); - when(repository.save(any(WhatsAppTemplate.class))).thenAnswer(invocation -> invocation.getArgument(0)); + assertNull(result.accounts().get(0).error()); + assertEquals(1, result.synced()); + } - List upserted = service.sync(connector); + @Test + void sync_shouldKeepSyncingOtherAccounts_whenOneFails() { + givenConnectors(connector("111", WABA, "revoked"), connector("333", OTHER_WABA, "good")); + when(restTemplate.exchange(eq(GRAPH + "/" + WABA + "/message_templates?limit=100"), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenThrow(expiredToken()); + metaReturns(OTHER_WABA, Map.of("data", List.of(template("META_2", "otp")))); - assertEquals(1, upserted.size()); - assertEquals("farm_alert", upserted.get(0).getName()); - assertEquals("APPROVED", upserted.get(0).getStatus()); - assertTrue(upserted.get(0).getPlaceholdersJson().contains("\"position\":1")); + WhatsAppTemplateSyncResultDTO result = service.syncForCurrentUser(null); - ArgumentCaptor savedCaptor = ArgumentCaptor.forClass(WhatsAppTemplate.class); - org.mockito.Mockito.verify(repository, org.mockito.Mockito.times(2)).save(savedCaptor.capture()); - WhatsAppTemplate removed = savedCaptor.getAllValues().stream() - .filter(t -> "META_ID_STALE".equals(t.getMetaTemplateId())).findFirst().orElseThrow(); - assertEquals(WhatsAppTemplate.STATUS_REMOVED, removed.getStatus()); + assertTrue(result.accounts().get(0).error().contains("invalid or has expired")); + assertEquals(1, result.accounts().get(1).synced()); + } + + @Test + void sync_shouldOnlySyncTheRequestedAccount() { + givenConnectors(connector("111", WABA, "t"), connector("333", OTHER_WABA, "t")); + metaReturns(OTHER_WABA, Map.of("data", List.of())); + + WhatsAppTemplateSyncResultDTO result = service.syncForCurrentUser(OTHER_WABA); + + assertEquals(List.of(OTHER_WABA), result.accounts().stream().map(WhatsAppTemplateSyncResultDTO.Account::businessAccountId).toList()); + assertThrows(ResponseStatusException.class, () -> service.syncForCurrentUser("000")); + } + + @Test + void sync_shouldFollowCursorPagination() { + givenConnectors(connector("111", WABA, "t")); + metaReturns(WABA, Map.of("data", List.of(template("META_1", "a")), + "paging", Map.of("next", "https://graph.facebook.com/next", "cursors", Map.of("after", "CURSOR2")))); + when(restTemplate.exchange(eq(GRAPH + "/" + WABA + "/message_templates?limit=100&after=CURSOR2"), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("data", List.of(template("META_2", "b"))))); + + assertEquals(2, service.syncForCurrentUser(null).synced()); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaServiceTest.java index cb77a15..2329ed9 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaServiceTest.java @@ -52,7 +52,7 @@ class WhatsAppMetaServiceTest { @BeforeEach void setUp() { ObjectMapper objectMapper = new ObjectMapper(); - service = new WhatsAppMetaService(new MetaGraphClient(restTemplate, objectMapper), connectorRepository, currentUserResolver, objectMapper); + service = new WhatsAppMetaService(new MetaGraphClient(restTemplate, objectMapper), connectorRepository, currentUserResolver); } private void stubGet(String path, Map body) { From 1210bb9c5798bb8245c9238cad8573e0a2b038b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Oct 2026 04:45:12 +0000 Subject: [PATCH 2/2] Release v0.0.86 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 4e44d81..b383fcc 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.85' +version = '0.0.86' description = 'Flextuma App' java {