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.85'
version = '0.0.86'
description = 'Flextuma App'

java {
Expand Down
12 changes: 12 additions & 0 deletions docs/whatsapp-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
public interface WhatsAppTemplateRepository extends BaseRepository<WhatsAppTemplate, UUID>,
JpaSpecificationExecutor<WhatsAppTemplate> {

List<WhatsAppTemplate> 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<WhatsAppTemplate> findByCreatedBy(User createdBy);

/** Send-time lookup: any number in the account can send the account's templates. */
Optional<WhatsAppTemplate> findFirstByNameAndLanguageAndBusinessAccountIdAndCreatedBy(String name, String language,
String businessAccountId, User createdBy);

Optional<WhatsAppTemplate> findByNameAndLanguageAndConnectorAndCreatedBy(String name, String language,
SmsConnector connector, User createdBy);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -122,12 +123,10 @@ public SmsLog queueWhatsAppTemplate(Map<String, Object> 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() + ")");
Expand Down Expand Up @@ -168,6 +167,18 @@ public SmsLog queueWhatsAppTemplate(Map<String, Object> 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<WhatsAppTemplate> findTemplateForConnector(String name, String language, SmsConnector connector) {
String businessAccountId = WhatsAppConnectorSettings.businessAccountIdOf(connector);
Optional<WhatsAppTemplate> 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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<List<WhatsAppTemplate>> sync() {
return ResponseEntity.ok(syncService.syncForCurrentUser());
public ResponseEntity<WhatsAppTemplateSyncResultDTO> sync(@RequestParam(required = false) String businessAccountId) {
return ResponseEntity.ok(syncService.syncForCurrentUser(businessAccountId));
}
}
Original file line number Diff line number Diff line change
@@ -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<Account> 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<String> phoneNumberIds, int synced, int removed, String error) {
}
}
Loading