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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ POST /api/notifications/whatsapp

The message is queued and tracked in `/api/smsLogs` alongside SMS; WhatsApp delivery IDs and delivered/read/failed events update that log.

Only free-text messages are supported today — there is no Meta-approved
WhatsApp Business template support, so Meta will reject this call for any
business-initiated message sent outside the 24-hour customer-service window.
See [`docs/whatsapp-templates.md`](docs/whatsapp-templates.md) for the gap
and an implementation guide to add it.

#### Shared system connectors and safe use

When a customer does not have an active connector for the selected provider, Flextuma can intentionally fall back to a matching `{PROVIDER}_SYSTEM` connector. This is Flextuma's paid shared infrastructure, not access to another customer's credentials. The send is always attributed to the authenticated user and must debit that user's wallet before a message log is queued. The charge is the configured per-segment price multiplied by the actual segment count.
Expand Down
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.73'
version = '0.0.74'
description = 'Flextuma App'

java {
Expand Down
1 change: 1 addition & 0 deletions docs/third-party-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ These are code-observed findings as of this repository revision, ordered by impa
| High | API-driven recipient hydration accepts arbitrary stored URLs and caller-controlled query filters without egress controls, timeouts, size limits, or pagination. | Creates SSRF, resource exhaustion, and unintended data-exposure risk. Enforce HTTPS/host allowlists, block private/link-local ranges, set connect/read timeouts and response limits, validate filters, paginate, and audit access. |
| Partially resolved | SMS and campaign workers now use atomic conditional status updates to claim work. | This prevents concurrent replicas from claiming the same PENDING/SCHEDULED row. Add provider idempotency keys and a lease/recovery policy for rows left `PROCESSING` after process failure. |
| High | Campaign dispatch catches errors but can leave campaigns in `PROCESSING`; it also completes after per-recipient debit failures without an explicit partial-failure result. | Operators cannot reliably recover or reconcile campaigns. Model failed/partial states, persist per-recipient outcomes, and alert on stuck campaigns. |
| Resolved | The WhatsApp Cloud API sender only supported free-text messages (`type: "text"`); there was no support for Meta-approved WhatsApp Business templates. | `WhatsAppTemplate` syncs Meta-approved templates per connector, `WhatsAppSender.sendTemplate` sends `type: "template"` messages, and `POST /api/notifications/whatsapp` routes to it when `templateName` is present (validated against a synced `APPROVED` template first). Template status updates from the `message_template_status_update` webhook event keep synced rows current. See [the WhatsApp templates gap and implementation guide](whatsapp-templates.md). |
| Medium | Production deployment defaults are unsafe: development Compose, Hibernate `update`, DevTools, mutable bind mounts, `/tmp` uploads, and no health endpoint/migration framework. | Releases are not reproducible or safely observable. Follow [the deployment guide](deployment.md) and add Actuator plus Flyway/Liquibase. |
| Medium | Global CSRF is disabled while cookie sessions are used. | Browser-authenticated write endpoints are exposed to CSRF risk. Enable CSRF protection for session flows, or separate browser/session and token API security models. |
| Medium | Security/operability controls are incomplete: no request timeout for legacy `RestTemplate`, no circuit breaker, no outbound provider rate/concurrency control, no OpenAPI contract, and limited metrics. | Failures are harder to contain, diagnose, and integrate against. Add timeouts, retries with jitter, circuit breaking, metrics/alerts, and a versioned OpenAPI specification. |
Expand Down
311 changes: 311 additions & 0 deletions docs/whatsapp-templates.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package com.flexcodelabs.flextuma.core.entities.whatsapp;

import com.flexcodelabs.flextuma.core.entities.base.Owner;
import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.LocalDateTime;

/**
* Mirrors a Meta-approved WhatsApp Business template. Synced from the Graph API by
* {@code WhatsAppTemplateSyncService} and kept current by the webhook's
* {@code message_template_status_update} handler -- never hand-authored by a tenant, which is why
* {@link #ADD}/{@link #UPDATE}/{@link #DELETE} require a permission no ordinary tenant role is
* granted (see {@code WhatsAppTemplateService}).
*/
@Entity
@Table(name = "whatsapp_template", uniqueConstraints = {
@UniqueConstraint(name = "unique_meta_template_id", columnNames = { "meta_template_id", "creator" })
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class WhatsAppTemplate extends Owner {
public static final String PLURAL = "whatsappTemplates";
public static final String NAME_PLURAL = "WhatsApp Templates";
public static final String NAME_SINGULAR = "WhatsApp Template";

public static final String READ = "ALL";
public static final String ADD = "ADD_WHATSAPP_TEMPLATES";
public static final String UPDATE = "UPDATE_WHATSAPP_TEMPLATES";
public static final String DELETE = "DELETE_WHATSAPP_TEMPLATES";

/** REMOVED marks a template Meta no longer returns on sync, so a send configuration
* referencing it fails validation instead of dangling on a deleted row. */
public static final String STATUS_REMOVED = "REMOVED";
public static final String STATUS_APPROVED = "APPROVED";

@NotBlank
@Column(name = "meta_template_id", nullable = false)
private String metaTemplateId;

@NotBlank
@Column(nullable = false)
private String name;

private String category;

@NotBlank
@Column(nullable = false)
private String language;

private String status;

/** Raw components array Meta returned (HEADER/BODY/BUTTONS), as JSON text -- kept as TEXT,
* not a native jsonb type, to match Hibernate's unattended ddl-auto=update schema management
* (no migration framework yet; see docs/third-party-integration.md). */
@Column(columnDefinition = "TEXT")
private String componentsJson;

/** Derived {{n}} placeholder list (component/type/position), as JSON text. */
@Column(columnDefinition = "TEXT")
private String placeholdersJson;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "connector", nullable = false)
private SmsConnector connector;

@Column(name = "last_synced_at")
private LocalDateTime lastSyncedAt;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.flexcodelabs.flextuma.core.repositories;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

Expand All @@ -15,6 +16,11 @@ public interface SmsConnectorRepository extends BaseRepository<SmsConnector, UUI

Optional<SmsConnector> findByCreatedByAndProviderAndActiveTrue(User createdBy, String provider);

/** Used by WhatsAppTemplateSyncService: a tenant may own more than one active WHATSAPP
* connector (e.g. multiple WABAs), unlike the single-connector assumption the send path's
* findByCreatedByAndProviderAndActiveTrue above makes. */
List<SmsConnector> findAllByCreatedByAndProviderAndActiveTrue(User createdBy, String provider);

Optional<SmsConnector> findFirstByCreatedByAndActiveTrue(User createdBy);

Optional<SmsConnector> findByProviderAndCode(String provider, String code);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.flexcodelabs.flextuma.core.repositories;

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 org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface WhatsAppTemplateRepository extends BaseRepository<WhatsAppTemplate, UUID>,
JpaSpecificationExecutor<WhatsAppTemplate> {

List<WhatsAppTemplate> findByConnectorAndCreatedBy(SmsConnector connector, User createdBy);

Optional<WhatsAppTemplate> findByNameAndLanguageAndConnectorAndCreatedBy(String name, String language,
SmsConnector connector, User createdBy);

/** Used by the webhook's message_template_status_update handler, which has no tenant context
* of its own -- metaTemplateId is unique per (id, creator), so findFirst is safe here. */
Optional<WhatsAppTemplate> findFirstByMetaTemplateId(String metaTemplateId);

/** Fallback lookup for a status update payload that omits message_template_id. */
Optional<WhatsAppTemplate> findFirstByNameAndLanguage(String name, String language);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.web.client.RestTemplate;

import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/** WhatsApp Cloud API text-message sender. The connector key is a Meta access token. */
Expand Down Expand Up @@ -58,6 +59,48 @@ public SmsSendResult sendSms(SmsConnector config, String to, String message) {
}
}

/** Sends a Meta-approved WhatsApp Business template message (type: "template"), the only kind
* of business-initiated message Meta accepts outside the 24h customer-service window. This is
* intentionally not on the shared {@link SmsSender} interface: no other provider has an
* equivalent concept, and the caller (WhatsAppTemplateSendService-style code) already knows
* it's talking to WhatsApp specifically. {@code components} is the raw Meta components array
* (header/body text or media parameters, dynamic-URL button parameters) -- passed through
* as-is, not built here. */
public SmsSendResult sendTemplate(SmsConnector config, String to, String templateName, String languageCode,
List<Map<String, Object>> components) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(config.getKey());

Map<String, Object> template = new LinkedHashMap<>();
template.put("name", templateName);
template.put("language", Map.of("code", languageCode));
if (components != null && !components.isEmpty()) {
template.put("components", components);
}

Map<String, Object> body = new LinkedHashMap<>();
body.put("messaging_product", "whatsapp");
body.put("to", normaliseRecipient(to));
body.put("type", "template");
body.put("template", template);

ResponseEntity<Map> response = restTemplate.postForEntity(messageUrl(config),
new HttpEntity<>(body, headers), Map.class);
Map<String, Object> responseBody = objectMapper.convertValue(response.getBody(), new TypeReference<>() {});
String messageId = extractMessageId(responseBody);
if (response.getStatusCode().is2xxSuccessful() && messageId != null) {
return SmsSendResult.success("WhatsApp template message accepted", messageId, responseBody);
}
return SmsSendResult.failure("WhatsApp API did not return a message id",
String.valueOf(response.getStatusCode().value()), responseBody);
} catch (Exception e) {
return SmsSendResult.failure("Failed to send WhatsApp template message: " + e.getMessage(), "SEND_ERROR",
Map.of("error", e.getMessage()));
}
}

/** Tells Meta a message was read, so the sender sees blue double-ticks. Never throws --
* this is best-effort: Meta's API hiccuping shouldn't block marking a message read locally. */
public boolean markAsRead(SmsConnector config, String providerMessageId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import com.flexcodelabs.flextuma.modules.dashboard.services.DashboardService;
import com.flexcodelabs.flextuma.modules.notification.services.NotificationService;

import java.util.LinkedHashMap;
import java.util.Map;

@RestController
Expand Down Expand Up @@ -50,11 +51,21 @@ public ResponseEntity<SmsLog> sendRaw(
return ResponseEntity.ok(log);
}

/** Queues a WhatsApp Cloud API text message using the caller's WHATSAPP connector. */
/** Queues a WhatsApp Cloud API message using the caller's WHATSAPP connector. When
* {@code templateName} is present, sends a Meta-approved Business template (the only kind of
* business-initiated message Meta accepts outside the 24h customer-service window); otherwise
* behaves exactly as before and sends free-form text. */
@PostMapping("/whatsapp")
public ResponseEntity<SmsLog> sendWhatsApp(@RequestBody Map<String, String> payload,
public ResponseEntity<SmsLog> sendWhatsApp(@RequestBody Map<String, Object> payload,
java.security.Principal principal) {
payload.put("provider", "WHATSAPP");
return ResponseEntity.ok(notificationService.queueRawSms(payload, principal.getName()));
Object templateName = payload.get("templateName");
if (templateName != null && !templateName.toString().isBlank()) {
return ResponseEntity.ok(notificationService.queueWhatsAppTemplate(payload, principal.getName()));
}

Map<String, String> textPayload = new LinkedHashMap<>();
payload.forEach((key, value) -> textPayload.put(key, value == null ? null : value.toString()));
textPayload.put("provider", "WHATSAPP");
return ResponseEntity.ok(notificationService.queueRawSms(textPayload, principal.getName()));
}
}
Loading