diff --git a/README.md b/README.md index e15c441..9820910 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/build.gradle b/build.gradle index 7f14e78..b8e851e 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.73' +version = '0.0.74' description = 'Flextuma App' java { diff --git a/docs/third-party-integration.md b/docs/third-party-integration.md index 48df74a..eb94f0a 100644 --- a/docs/third-party-integration.md +++ b/docs/third-party-integration.md @@ -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. | diff --git a/docs/whatsapp-templates.md b/docs/whatsapp-templates.md new file mode 100644 index 0000000..7178bc2 --- /dev/null +++ b/docs/whatsapp-templates.md @@ -0,0 +1,311 @@ +# WhatsApp Business templates — gap, target design, and implementation guide + +**Status: implemented.** The design below shipped as written: [`WhatsAppTemplate`](../src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppTemplate.java) +(synced catalogue), [`WhatsAppTemplateSyncService`](../src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncService.java) +(`POST /api/whatsappTemplates/sync`), [`WhatsAppSender.sendTemplate`](../src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java), +[`NotificationService.queueWhatsAppTemplate`](../src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java) +(routed from `POST /api/notifications/whatsapp` when `templateName` is present), and the +`message_template_status_update` handling in +[`WhatsAppWebhookController`](../src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java). +The rest of this document is kept as the design record; the "Current state" section below +describes what was true *before* this work landed. + +Flextuma's WhatsApp Cloud API integration could previously send only free-form +text messages. It cannot send a **Meta-approved WhatsApp Business template** +message. This is not a minor gap: it blocks every tenant whose WhatsApp use +case is proactive (reminders, alerts, invoices, OTPs) rather than a live +support conversation, because Meta's Cloud API rejects business-initiated +free text outside a narrow window. This document explains the gap, the +target design, and the concrete steps to close it. + +## Why this matters + +WhatsApp's Cloud API only allows **business-initiated** messages — anything +a tenant's system sends proactively, outside a 24-hour customer-service +window following the recipient's last inbound message — to use a template +Meta has already reviewed and approved. Free-form text sent outside that +window is rejected by Meta (error `131047`, "re-engagement message"). A +template is not just copy formatting: it is a Meta object with a `name`, +`language`, `category`, an approval `status`, and a fixed set of `HEADER` / +`BODY` / `BUTTON` components, each with typed placeholders (`{{1}}`, +`{{2}}`, …) that get filled in per send. + +So any tenant whose WhatsApp traffic is scheduler-driven — not a reply to an +inbound chat — needs Flextuma to know how to send `type: "template"` +messages, not just `type: "text"`. + +## Current state (as of this revision) + +Confirmed by reading the code, not inferred from the README: + +- [`WhatsAppSender.sendSms`](../src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java) + hardcodes `"type": "text"` with `text.body`. There is no `type: "template"` + branch anywhere in the class. +- [`SmsSender`](../src/main/java/com/flexcodelabs/flextuma/core/services/SmsSender.java), + the interface every provider (Beem, NextSms, WhatsApp) implements, is + `sendSms(SmsConnector config, String to, String message)` — a flat string. + There is no parameter for a template name, language, or structured + components, for any provider. +- [`NotificationController.sendWhatsApp`](../src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java) + (`POST /api/notifications/whatsapp`) just sets `provider: WHATSAPP` and + calls the same `queueRawSms` free-text path used for SMS. +- `/api/templates` ([`SmsTemplate`](../src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsTemplate.java)) + is Flextuma's own `{{variable}}`-interpolation template system for SMS/text + copy. It has nothing to do with Meta-approved WhatsApp templates — it + produces a free-text `content` string, which is exactly what + `WhatsAppSender` still sends as `type: "text"` even when the provider is + WhatsApp. There is no entity anywhere that represents a Meta WhatsApp + Business template (id, category, language, approval status, components). +- The webhook relay + ([`WhatsAppWebhookController`](../src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java)) + already forwards *every* Meta event type verbatim to a tenant's + `callbackUrl` (see `relay()`), including a `message_template_status_update` + event — it just isn't specially interpreted here, and nothing on this side + currently syncs or stores templates to correlate that event against. Note + it only arrives on the per-tenant generated callback route + (`POST /api/webhooks/whatsapp/{callbackToken}`), not the phone-number-keyed + route, because template status events carry no `metadata.phone_number_id` + for `phoneNumberId(payload)` to match against. +- Neither `ROADMAP/roadmap.md` nor `docs/third-party-integration.md` lists + WhatsApp template support as planned work today; the only planned + WhatsApp item is Meta Tech Provider / Embedded Signup onboarding (how a + tenant *obtains* Meta credentials, unrelated to what gets sent with them). + +## Target design + +### 1. `WhatsAppTemplate` entity — mirrors Meta's template object + +A new entity, synced from Meta's Graph API rather than hand-authored, +following the same `Owner`/`BaseEntity` pattern as every other module: + +```java +package com.flexcodelabs.flextuma.core.entities.whatsapp; + +@Entity +@Table(name = "whatsapp_template", uniqueConstraints = { + @UniqueConstraint(name = "unique_meta_template_id", columnNames = { "metaTemplateId", "creator" }) +}) +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"; + + private String metaTemplateId; // Meta's template id + private String name; // Template name, e.g. "farm_alert" + private String category; // UTILITY | MARKETING | AUTHENTICATION + private String language; // e.g. "en", "sw" + private String status; // APPROVED | PENDING | REJECTED | PAUSED | DISABLED | ... + + @Column(columnDefinition = "TEXT") + private String componentsJson; // Raw components array Meta returned, as JSON text + + @Column(columnDefinition = "TEXT") + private String placeholdersJson; // Derived placeholder list (type/format/position), as JSON text + + @ManyToOne(optional = false) + private SmsConnector connector; // Which WHATSAPP connector this was synced from + + private LocalDateTime lastSyncedAt; +} +``` + +Store `componentsJson`/`placeholdersJson` as JSON text columns (matching +`SmsConnector.extraSettings`'s existing `columnDefinition = "TEXT"` +convention) rather than a native `jsonb` type — Flextuma's schema is +Hibernate-managed (`spring.jpa.hibernate.ddl-auto=update`, see +`src/main/resources/application.properties`), and there is no migration +framework yet (tracked as a gap in +[`third-party-integration.md`](third-party-integration.md)), so keep new +columns to types Hibernate can create unattended. A template Meta no longer +returns should be marked `status: "REMOVED"` on the next sync rather than +deleted, so any existing send configuration referencing it doesn't dangle — +this mirrors how flexfarm-core's own `whatsapp-template.entity.ts` already +handles the same case. + +Wire it up with the same three-file pattern every other module uses — +compare directly against `SmsConnector`'s equivalents: + +- `WhatsAppTemplateRepository extends JpaRepository, JpaSpecificationExecutor<...>` +- `WhatsAppTemplateService extends BaseService` (see + [`SmsConnectorService`](../src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java) + for the exact override shape: `getRepository`, `getReadPermission` / + `getAddPermission` / `getUpdatePermission` / `getDeletePermission`, + `getEntityPlural`, `getEntitySingular`, `getPropertyName`, + `getRepositoryAsExecutor`, `getTableName`) +- `WhatsAppTemplateController extends BaseController` + mounted at `/api/` + `WhatsAppTemplate.PLURAL` + +This gives read-only CRUD browsing (`GET /api/whatsappTemplates`, +`GET /api/whatsappTemplates/{id}`) for free, matching the shared-CRUD +conventions already documented in the main [README](../README.md#shared-crud-endpoints). +Block `POST`/`PUT`/`DELETE` at the service layer (return `403` or simply +omit `ADD`/`UPDATE`/`DELETE` permissions) — templates are synced, not +hand-authored, exactly like `whatsapp-template.entity.ts` already enforces +on the flexfarm-core side. + +### 2. Template sync + +Add `WhatsAppTemplateSyncService.sync(SmsConnector connector)`: + +1. `GET https://graph.facebook.com/{version}/{wabaId}/message_templates?limit=100` + using the connector's `key` as a Bearer token, following + `paging.next` to exhaustion. The WABA id isn't currently a `SmsConnector` + field — add it to `extraSettings` (e.g. `{"businessAccountId":"..."}`) or + as a new `businessAccountId` column; either fits the entity's existing + free-form-JSON escape hatch or its column-per-provider-setting pattern. +2. Upsert each returned template by `(metaTemplateId, connector.createdBy)`, + deriving `placeholdersJson` from each component's `{{n}}` count/format — + port the logic flexfarm-core already wrote for this in + `whatsapp-template.service.ts` rather than re-deriving it from scratch. +3. Mark any previously-synced template no longer returned as + `status = "REMOVED"`. + +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. + +### 3. Sending a template message + +Add a template-send path alongside the existing free-text path, without +changing existing behavior: + +**Sender**, extend `WhatsAppSender` with a second method (the shared +`SmsSender` interface stays as-is for the free-text path every provider +still uses; this is WhatsApp-specific, so it doesn't need to live on the +interface): + +```java +public SmsSendResult sendTemplate(SmsConnector config, String to, String templateName, + String languageCode, List> components) { + Map body = new LinkedHashMap<>(); + body.put("messaging_product", "whatsapp"); + body.put("to", normaliseRecipient(to)); + body.put("type", "template"); + body.put("template", Map.of( + "name", templateName, + "language", Map.of("code", languageCode), + "components", components)); + // ... same RestTemplate POST + SmsSendResult mapping as sendSms() +} +``` + +This is exactly the shape flexfarm-core's own Meta client already builds in +[`notification-whatsapp.service.ts`](../../../flexfarm/flexfarm-core/src/notification/services/notification-whatsapp.service.ts) +(`sendTemplateMessage`) — reuse that as the reference for the `components` +array's per-type structure (`header`/`body` text parameters, `header` media +parameters as `{type: "image", image: {link: url}}`, and `button` dynamic-URL +parameters keyed by `sub_type: "url"` + `index`). + +**API surface**: extend `NotificationController.sendWhatsApp` to branch on +whether the request carries template fields: + +```json +POST /api/notifications/whatsapp +{ + "phoneNumber": "+255700000000", + "templateName": "farm_alert", + "templateLanguage": "en", + "components": [ + { "type": "body", "parameters": [{ "type": "text", "text": "Feed is running low" }] } + ] +} +``` + +When `templateName` is present, `NotificationService` should route to +`WhatsAppTemplateSendService` (new, mirrors `queueRawSms`'s shape: resolve +the connector, validate rate limit, save an `SmsLog` with `content` set to a +human-readable rendering of the template send for the dashboard, and call +`WhatsAppSender.sendTemplate` instead of `sendSms`). When `templateName` is +absent, behavior is byte-for-byte unchanged — existing integrations keep +working. + +Do not require the caller to look up `WhatsAppTemplate` rows itself for the +component values — callers (like flexfarm-core) already own "which template +for which event, filled with which values" in their own domain; Flextuma's +job is only to relay a pre-built `components` array to Meta and track the +resulting `SmsLog`. Keep the template *catalogue* (`WhatsAppTemplate`, +step 1) for validation/browsing (e.g. reject a send whose `templateName` + +`templateLanguage` doesn't match a `status: "APPROVED"` synced row, the way +flexfarm-core already refuses to send through an unapproved assignment) — +but don't force callers through a second admin UI to configure a +`variableMappings`-style indirection Flextuma doesn't need to own. + +### 4. Template status stays in sync + +No new work needed on the receiving side: the webhook relay already +forwards `message_template_status_update` events verbatim to a tenant's +`callbackUrl` (`WhatsAppWebhookController.relay()`). Add one thing: +special-case that event type in `WhatsAppWebhookController.handle()` to also +update the matching local `WhatsAppTemplate.status` (by `metaTemplateId`, +falling back to name+language) before relaying, the same way +flexfarm-core's own `AppController` → `WhatsappTemplateService.applyStatusUpdate` +already does — so an approval/rejection is reflected in +`GET /api/whatsappTemplates` immediately instead of waiting for the next +manual sync. + +## Implementation guide (ordered) + +1. **Entity + repository** — add `WhatsAppTemplate` under + `core/entities/whatsapp/`, `WhatsAppTemplateRepository` under + `core/repositories/`. Follow `SmsConnector`/`SmsConnectorRepository` + exactly for annotations and conventions. +2. **Service + controller** — add `WhatsAppTemplateService extends BaseService` + and `WhatsAppTemplateController extends BaseController<...>` under + `modules/whatsapp/services/` and `modules/whatsapp/controllers/`, + read-only permissions only. +3. **Sync** — add `WhatsAppTemplateSyncService` with the Graph API fetch + + upsert logic from "Template sync" above, and wire + `POST /api/whatsappTemplates/sync` into `WhatsAppTemplateController`. + Add `businessAccountId` (or an `extraSettings` key) to `SmsConnector` if a + dedicated column is preferred over the JSON escape hatch. +4. **Sender** — add `WhatsAppSender.sendTemplate(...)` per "Sending a + template message" above. Unit test it the same way + `WhatsAppSenderTest` (if one exists) covers `sendSms` today — assert the + POST body shape, not just that a call was made. +5. **Send path** — add `WhatsAppTemplateSendService` (or extend + `NotificationService`) to branch `POST /api/notifications/whatsapp` on + `templateName` presence, validate the named template is + `status: "APPROVED"` for the caller's connector, build `components` from + the request, and call the new sender method. Reuse `SmsLog` for tracking + — no new log table needed. +6. **Webhook status sync** — extend `WhatsAppWebhookController.handle()` to + update the matching `WhatsAppTemplate.status` when the payload's + `changes[].field` is `message_template_status_update`, before the + existing `relay()` call runs. +7. **Tests** — cover: sync upserts a new template and marks a + Meta-removed one `REMOVED`; send is rejected when no `APPROVED` template + matches `(templateName, templateLanguage, connector)`; send builds the + exact Meta payload shape for text/media header, body, and dynamic-URL + button components; a `message_template_status_update` webhook event + updates the local row *and* still relays to the tenant's `callbackUrl`; + existing free-text `POST /api/notifications/whatsapp` behavior is + unchanged when no `templateName` is supplied. +8. **Docs** — update this file's "Current state" section once shipped, and + add a row to `docs/third-party-integration.md`'s gap table marking this + "Resolved" (see the existing rows there for the format). + +## What does *not* need to change + +- The `SmsSender` interface, `BeemSender`, and `NextSmsSender` are + untouched — the new method is WhatsApp-specific and additive. +- Existing free-text `POST /api/notifications/whatsapp` callers are + unaffected; the template path only activates when `templateName` is + present in the request body. +- Connector setup (`POST /api/connectors` with `provider: "WHATSAPP"`), + billing (system-connector-only wallet debit), rate limiting (10/sec/tenant + via `RateLimiterService`), and the `WHATSAPP_SEND` feature gate all + already work as-is and need no changes for template sends. + +## Minimum acceptance tests + +Following the same convention as +[`third-party-integration.md`](third-party-integration.md#minimum-acceptance-tests): +sync creates/updates/removes templates correctly against a mocked Graph API +response; a send with a non-existent or non-approved `templateName` is +rejected before any Meta call; a send with a valid template produces the +exact `type: "template"` payload shape for each component kind (text +header, media header, body, dynamic-URL button); a duplicate or malformed +`message_template_status_update` webhook doesn't corrupt template state; +relay to the tenant `callbackUrl` still fires for that event type; and the +existing free-text send path has zero behavior change under this feature's +test suite. 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 new file mode 100644 index 0000000..53005e4 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppTemplate.java @@ -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; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java index f97a9d8..fc172f5 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java @@ -1,5 +1,6 @@ package com.flexcodelabs.flextuma.core.repositories; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -15,6 +16,11 @@ public interface SmsConnectorRepository extends BaseRepository 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 findAllByCreatedByAndProviderAndActiveTrue(User createdBy, String provider); + Optional findFirstByCreatedByAndActiveTrue(User createdBy); Optional findByProviderAndCode(String provider, String code); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java new file mode 100644 index 0000000..7bb9c5b --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java @@ -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, + JpaSpecificationExecutor { + + List findByConnectorAndCreatedBy(SmsConnector connector, User createdBy); + + Optional 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 findFirstByMetaTemplateId(String metaTemplateId); + + /** Fallback lookup for a status update payload that omits message_template_id. */ + Optional findFirstByNameAndLanguage(String name, String language); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java b/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java index 3519aaf..20f5924 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java @@ -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. */ @@ -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> components) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(config.getKey()); + + Map template = new LinkedHashMap<>(); + template.put("name", templateName); + template.put("language", Map.of("code", languageCode)); + if (components != null && !components.isEmpty()) { + template.put("components", components); + } + + Map body = new LinkedHashMap<>(); + body.put("messaging_product", "whatsapp"); + body.put("to", normaliseRecipient(to)); + body.put("type", "template"); + body.put("template", template); + + ResponseEntity response = restTemplate.postForEntity(messageUrl(config), + new HttpEntity<>(body, headers), Map.class); + Map 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) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java index 7ce6432..57f6e91 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java @@ -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 @@ -50,11 +51,21 @@ public ResponseEntity 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 sendWhatsApp(@RequestBody Map payload, + public ResponseEntity sendWhatsApp(@RequestBody Map 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 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())); } } 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 11bf543..bee2230 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 @@ -12,6 +12,7 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; 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.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; import com.flexcodelabs.flextuma.core.helpers.SmsSegmentResult; @@ -21,11 +22,16 @@ import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; +import com.flexcodelabs.flextuma.core.senders.WhatsAppSender; import com.flexcodelabs.flextuma.core.services.EntityAssociationReferenceResolver; import com.flexcodelabs.flextuma.core.services.EntityResponseInitializer; +import com.flexcodelabs.flextuma.core.services.SmsSendResult; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import com.flexcodelabs.flextuma.core.services.RateLimiterService; import com.flexcodelabs.flextuma.core.security.ApiTokenContext; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import org.springframework.beans.factory.annotation.Value; @@ -46,6 +52,8 @@ public class NotificationService { private final SmsSegmentCalculator segmentCalculator; private final EntityResponseInitializer entityResponseInitializer; private final EntityAssociationReferenceResolver entityAssociationReferenceResolver; + private final WhatsAppTemplateRepository whatsAppTemplateRepository; + private final WhatsAppSender whatsAppSender; @Value("${flextuma.sms.price-per-segment:1.0}") private BigDecimal pricePerSegment; @@ -97,6 +105,117 @@ public SmsLog queueRawSms(Map payload, String username) { return processAndSaveSms(currentUser, connector, phoneNumber, content, null, payload); } + /** Sends a Meta-approved WhatsApp Business template message. Unlike {@link #queueRawSms}, + * this calls {@link WhatsAppSender#sendTemplate} synchronously and records the outcome + * immediately -- there's no async dispatch path for it, since SmsDispatchWorker only knows + * the generic {@code SmsSender#sendSms(connector, to, message)} shape, not template + * components. */ + @Transactional + public SmsLog queueWhatsAppTemplate(Map payload, String username) { + User currentUser = getUser(username); + checkRateLimit(currentUser); + + String phoneNumber = getRequiredObjectField(payload, "phoneNumber"); + String templateName = getRequiredObjectField(payload, "templateName"); + String templateLanguage = getRequiredObjectField(payload, "templateLanguage"); + List> components = extractComponents(payload.get("components")); + + SmsConnector connector = getConnector(currentUser, "WHATSAPP", stringOrNull(payload.get("connectorId"))); + + WhatsAppTemplate template = whatsAppTemplateRepository + .findByNameAndLanguageAndConnectorAndCreatedBy(templateName, templateLanguage, connector, + connector.getCreatedBy()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "No synced WhatsApp template found for [" + templateName + "/" + templateLanguage + + "] on this connector")); + if (!WhatsAppTemplate.STATUS_APPROVED.equalsIgnoreCase(template.getStatus())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Template [" + templateName + "] is not approved (status: " + template.getStatus() + ")"); + } + + if (isSystemConnector(connector)) { + enforceSystemConnectorDailyLimit(currentUser, connector); + } + + SmsSendResult result = whatsAppSender.sendTemplate(connector, phoneNumber, templateName, templateLanguage, + components); + + String renderedContent = renderTemplateForLog(templateName, templateLanguage, components); + SmsLog log = new SmsLog(); + log.setRecipient(phoneNumber); + log.setContent(renderedContent); + log.setConnector(connector); + log.setStatus(result.isSuccess() ? SmsLogStatus.SENT : SmsLogStatus.FAILED); + log.setProviderResponse(result.getProviderResponse()); + log.setProviderMessageId(result.getProviderMessageId()); + if (!result.isSuccess()) { + log.setError(result.getMessage()); + } + log.setCreatedBy(currentUser); + + // Unlike processAndSaveSms's text path (which debits before an async send even + // attempts), the send result is already known here -- so billing only a + // successfully-accepted template send avoids charging a tenant for a rejected send. + if (isSystemConnector(connector) && result.isSuccess()) { + SmsSegmentResult segmentResult = segmentCalculator.calculate(renderedContent); + BigDecimal cost = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); + walletService.debit(currentUser, cost, + "System connector WHATSAPP template send to " + phoneNumber, null); + } + + entityAssociationReferenceResolver.resolve(log); + SmsLog savedLog = logRepository.save(log); + entityResponseInitializer.initialize(savedLog); + return savedLog; + } + + private String getRequiredObjectField(Map data, String key) { + Object value = data.get(key); + if (value == null || value.toString().isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, key + " is missing"); + } + return value.toString(); + } + + private String stringOrNull(Object value) { + return value == null ? null : value.toString(); + } + + @SuppressWarnings("unchecked") + private List> extractComponents(Object raw) { + if (!(raw instanceof List list)) { + return List.of(); + } + List> components = new ArrayList<>(); + for (Object item : list) { + if (item instanceof Map map) { + components.add((Map) map); + } + } + return components; + } + + /** Human-readable rendering of a template send for the SmsLog dashboard -- there's no + * free-text "content" for a template message, so this reconstructs one from the template + * name/language plus any text parameter values supplied. */ + @SuppressWarnings("unchecked") + private String renderTemplateForLog(String templateName, String templateLanguage, + List> components) { + StringBuilder sb = new StringBuilder("[Template: ").append(templateName).append("/") + .append(templateLanguage).append("]"); + for (Map component : components) { + if (!(component.get("parameters") instanceof List parameters)) { + continue; + } + for (Object param : parameters) { + if (param instanceof Map map && map.get("text") != null) { + sb.append(' ').append(map.get("text")); + } + } + } + return sb.toString(); + } + private User getUser(String username) { if (username == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated"); 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 new file mode 100644 index 0000000..1373a89 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppTemplateController.java @@ -0,0 +1,30 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate; +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.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/" + WhatsAppTemplate.PLURAL) +public class WhatsAppTemplateController extends BaseController { + private final WhatsAppTemplateSyncService syncService; + + public WhatsAppTemplateController(WhatsAppTemplateService service, WhatsAppTemplateSyncService syncService) { + super(service); + 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. */ + @PostMapping("/sync") + public ResponseEntity> sync() { + return ResponseEntity.ok(syncService.syncForCurrentUser()); + } +} 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 13af36e..9c5644c 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 @@ -4,12 +4,14 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppRelayDelivery; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.helpers.HmacUtil; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppRelayDeliveryRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppMediaService; import lombok.RequiredArgsConstructor; @@ -40,6 +42,7 @@ public class WhatsAppWebhookController { private final SmsLogRepository smsLogRepository; private final WhatsAppInboxMessageRepository inboxMessageRepository; private final WhatsAppRelayDeliveryRepository relayDeliveryRepository; + private final WhatsAppTemplateRepository templateRepository; private final WhatsAppMediaService mediaService; private final ObjectMapper objectMapper; @@ -111,7 +114,7 @@ private ResponseEntity handle(Map payload, String rawPaylo if (config.isEmpty()) { log.warn("Ignoring WhatsApp webhook with no active configuration"); return ResponseEntity.ok().build(); } if (!validMetaSignature(config.get(), rawPayload, signature)) { log.warn("Rejecting WhatsApp webhook with an invalid Meta signature for config [{}]", config.get().getId()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } markEventReceived(config.get()); - updateDeliveryStatus(payload); ingestInboundMessages(config.get(), payload); relay(config.get(), payload); + updateDeliveryStatus(payload); ingestInboundMessages(config.get(), payload); updateTemplateStatus(payload); relay(config.get(), payload); log.info("Processed WhatsApp webhook for config [{}]: {} change(s)", config.get().getId(), changes(payload).size()); return ResponseEntity.ok().build(); } @@ -134,6 +137,38 @@ private void updateDeliveryStatus(Map payload) { } } + /** Special-cases the message_template_status_update event: updates the matching local + * WhatsAppTemplate.status (by metaTemplateId, falling back to name+language) so an + * approval/rejection shows up in GET /api/whatsappTemplates immediately instead of waiting + * for the next manual sync. Runs before relay() so the tenant's own callbackUrl still gets + * the raw event either way. */ + @SuppressWarnings("unchecked") + private void updateTemplateStatus(Map payload) { + for (Map change : changes(payload)) { + if (!"message_template_status_update".equals(change.get("field"))) continue; + Map value = nestedMap(change, "value"); + Object event = value.get("event"); + if (event == null) continue; + + Object templateId = value.get("message_template_id"); + Optional template = templateId != null + ? templateRepository.findFirstByMetaTemplateId(templateId.toString()) + : Optional.empty(); + if (template.isEmpty()) { + Object name = value.get("message_template_name"); + Object language = value.get("message_template_language"); + if (name != null && language != null) { + template = templateRepository.findFirstByNameAndLanguage(name.toString(), language.toString()); + } + } + template.ifPresent(t -> { + t.setStatus(event.toString()); + t.setLastSyncedAt(LocalDateTime.now()); + templateRepository.save(t); + }); + } + } + // sent < delivered < read: Meta's status callbacks can arrive out of order (or duplicated), // and without this a late "delivered" retry could visibly regress an already-READ message's // blue ticks back to gray. FAILED isn't in this progression -- it's a terminal outcome, not diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateService.java new file mode 100644 index 0000000..1cce8c0 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateService.java @@ -0,0 +1,83 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +/** + * Read-only CRUD browsing for Meta-synced WhatsApp templates. READ uses the + * "ALL" sentinel (open + * to any tenant user, scoped to their own rows by BaseService's tenant + * filtering), but ADD/UPDATE/ + * DELETE require a dedicated permission no tenant role is ever granted -- so, + * unlike "ALL", those + * aren't satisfied by the generic tenant-user bypass in + * BaseService#checkPermission, and only + * SUPER_ADMIN (or a future explicit grant) can hit them via the inherited + * BaseController routes. + * Writes in practice happen only through WhatsAppTemplateSyncService and the + * webhook's + * message_template_status_update handler, both of which save via the repository + * directly. + */ +@Service +@RequiredArgsConstructor +public class WhatsAppTemplateService extends BaseService { + private final WhatsAppTemplateRepository repository; + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected String getReadPermission() { + return WhatsAppTemplate.READ; + } + + @Override + protected String getAddPermission() { + return WhatsAppTemplate.ADD; + } + + @Override + protected String getUpdatePermission() { + return WhatsAppTemplate.UPDATE; + } + + @Override + protected String getDeletePermission() { + return WhatsAppTemplate.DELETE; + } + + @Override + public String getEntityPlural() { + return WhatsAppTemplate.NAME_PLURAL; + } + + @Override + protected String getEntitySingular() { + return WhatsAppTemplate.NAME_SINGULAR; + } + + @Override + public String getPropertyName() { + return WhatsAppTemplate.PLURAL; + } + + @Override + protected String getTableName() { + return "whatsapp_template"; + } +} 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 new file mode 100644 index 0000000..0f48472 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncService.java @@ -0,0 +1,261 @@ +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.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; +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.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.Collectors; + +/** + * Pulls Meta-approved WhatsApp Business templates into {@link WhatsAppTemplate} rows. 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+)}}"); + + private final WhatsAppTemplateRepository repository; + private final SmsConnectorRepository connectorRepository; + private final CurrentUserResolver currentUserResolver; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + /** 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. */ + @Transactional + public List syncForCurrentUser() { + User currentUser = currentUserResolver.getCurrentUser() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated")); + + 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; + } + + @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"); + } + + 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; + } + + @SuppressWarnings("unchecked") + private String extractBusinessAccountId(SmsConnector connector) { + String extraSettings = connector.getExtraSettings(); + if (extraSettings == null || extraSettings.isBlank()) { + return null; + } + 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); + } + 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); + } + } + 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(); + } + + 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)); + 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)); + } + } + return placeholders; + } + + @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; + } + + 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 String stringOrNull(Object value) { + return value == null ? null : value.toString(); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSenderTest.java b/src/test/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSenderTest.java new file mode 100644 index 0000000..e490fde --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSenderTest.java @@ -0,0 +1,130 @@ +package com.flexcodelabs.flextuma.core.senders; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.services.SmsSendResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WhatsAppSenderTest { + + @Mock + private RestTemplate restTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private WhatsAppSender sender; + private SmsConnector config; + + @BeforeEach + void setUp() { + sender = new WhatsAppSender(restTemplate, objectMapper); + config = new SmsConnector(); + config.setUrl("https://graph.facebook.com/v21.0"); + config.setKey("test-token"); + config.setSenderId("104725069208652"); + } + + @SuppressWarnings("unchecked") + private Map capturedBody() { + ArgumentCaptor>> captor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).postForEntity(eq("https://graph.facebook.com/v21.0/104725069208652/messages"), + captor.capture(), eq(Map.class)); + return captor.getValue().getBody(); + } + + private void stubSuccessResponse() { + Map response = Map.of("messages", List.of(Map.of("id", "wamid.HBg"))); + when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(Map.class))) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + } + + @Test + void sendTemplate_shouldBuildTextBodyPayloadShape() { + stubSuccessResponse(); + List> components = List.of( + Map.of("type", "body", "parameters", List.of(Map.of("type", "text", "text", "Feed is running low")))); + + SmsSendResult result = sender.sendTemplate(config, "255712345678", "farm_alert", "en", components); + + assertTrue(result.isSuccess()); + assertEquals("wamid.HBg", result.getProviderMessageId()); + + Map body = capturedBody(); + assertEquals("whatsapp", body.get("messaging_product")); + assertEquals("255712345678", body.get("to")); + assertEquals("template", body.get("type")); + + Map template = (Map) body.get("template"); + assertEquals("farm_alert", template.get("name")); + assertEquals(Map.of("code", "en"), template.get("language")); + assertEquals(components, template.get("components")); + } + + @Test + void sendTemplate_shouldPassThroughMediaHeaderComponent() { + stubSuccessResponse(); + List> components = List.of( + Map.of("type", "header", "parameters", + List.of(Map.of("type", "image", "image", Map.of("link", "https://example.com/a.jpg"))))); + + sender.sendTemplate(config, "255712345678", "farm_alert", "en", components); + + Map template = (Map) capturedBody().get("template"); + assertEquals(components, template.get("components")); + } + + @Test + void sendTemplate_shouldPassThroughDynamicUrlButtonComponent() { + stubSuccessResponse(); + List> components = List.of( + Map.of("type", "button", "sub_type", "url", "index", "0", + "parameters", List.of(Map.of("type", "text", "text", "abc123")))); + + sender.sendTemplate(config, "255712345678", "farm_alert", "en", components); + + Map template = (Map) capturedBody().get("template"); + assertEquals(components, template.get("components")); + } + + @Test + void sendTemplate_shouldOmitComponentsKeyWhenNoneSupplied() { + stubSuccessResponse(); + + sender.sendTemplate(config, "255712345678", "otp_code", "en", List.of()); + + Map template = (Map) capturedBody().get("template"); + assertFalse(template.containsKey("components")); + } + + @Test + void sendTemplate_shouldReturnFailure_whenConnectionFails() { + when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(Map.class))) + .thenThrow(new org.springframework.web.client.ResourceAccessException("Connection failed")); + + SmsSendResult result = sender.sendTemplate(config, "255712345678", "farm_alert", "en", List.of()); + + assertFalse(result.isSuccess()); + assertEquals("SEND_ERROR", result.getErrorCode()); + } +} 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 2362029..6e46143 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 @@ -4,14 +4,18 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; 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.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.enums.SmsTemplateStatus; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.SmsTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.UserRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; +import com.flexcodelabs.flextuma.core.senders.WhatsAppSender; import com.flexcodelabs.flextuma.core.services.EntityAssociationReferenceResolver; import com.flexcodelabs.flextuma.core.services.EntityResponseInitializer; +import com.flexcodelabs.flextuma.core.services.SmsSendResult; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -71,6 +75,12 @@ class NotificationServiceTest { @Mock private EntityAssociationReferenceResolver entityAssociationReferenceResolver; + @Mock + private WhatsAppTemplateRepository whatsAppTemplateRepository; + + @Mock + private WhatsAppSender whatsAppSender; + @InjectMocks private NotificationService notificationService; @@ -254,4 +264,116 @@ void queueRawSms_shouldRejectAnotherUsersConnectorId() { assertEquals(HttpStatus.FORBIDDEN, ex.getStatusCode()); verifyNoInteractions(walletService); } + + private Map validTemplatePayload() { + Map payload = new HashMap<>(); + payload.put("phoneNumber", "+255700000000"); + payload.put("templateName", "farm_alert"); + payload.put("templateLanguage", "en"); + payload.put("components", java.util.List.of( + Map.of("type", "body", "parameters", java.util.List.of(Map.of("type", "text", "text", "Feed is low"))))); + return payload; + } + + @Test + void queueWhatsAppTemplate_shouldRejectWhenNoSyncedTemplateMatches() { + SmsConnector connector = new SmsConnector(); + connector.setProvider("WHATSAPP"); + connector.setCreatedBy(testUser); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + .thenReturn(Optional.of(connector)); + when(whatsAppTemplateRepository.findByNameAndLanguageAndConnectorAndCreatedBy("farm_alert", "en", + connector, testUser)).thenReturn(Optional.empty()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueWhatsAppTemplate(validTemplatePayload(), "testuser")); + + assertEquals(HttpStatus.NOT_FOUND, ex.getStatusCode()); + verifyNoInteractions(whatsAppSender); + } + + @Test + void queueWhatsAppTemplate_shouldRejectWhenTemplateNotApproved() { + SmsConnector connector = new SmsConnector(); + connector.setProvider("WHATSAPP"); + connector.setCreatedBy(testUser); + + WhatsAppTemplate template = new WhatsAppTemplate(); + template.setStatus("PENDING"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + .thenReturn(Optional.of(connector)); + when(whatsAppTemplateRepository.findByNameAndLanguageAndConnectorAndCreatedBy("farm_alert", "en", + connector, testUser)).thenReturn(Optional.of(template)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, + () -> notificationService.queueWhatsAppTemplate(validTemplatePayload(), "testuser")); + + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("not approved")); + verifyNoInteractions(whatsAppSender); + } + + @Test + void queueWhatsAppTemplate_shouldSendAndSaveSentLogOnSuccess() { + SmsConnector connector = new SmsConnector(); + connector.setProvider("WHATSAPP"); + connector.setCreatedBy(testUser); + + WhatsAppTemplate template = new WhatsAppTemplate(); + template.setStatus("APPROVED"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + .thenReturn(Optional.of(connector)); + when(whatsAppTemplateRepository.findByNameAndLanguageAndConnectorAndCreatedBy("farm_alert", "en", + connector, testUser)).thenReturn(Optional.of(template)); + when(whatsAppSender.sendTemplate(eq(connector), eq("+255700000000"), eq("farm_alert"), eq("en"), any())) + .thenReturn(SmsSendResult.success("accepted", "wamid.123", Map.of("messages", "ok"))); + when(logRepository.save(any(SmsLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + SmsLog result = notificationService.queueWhatsAppTemplate(validTemplatePayload(), "testuser"); + + verify(logRepository).save(smsLogCaptor.capture()); + SmsLog capturedLog = smsLogCaptor.getValue(); + assertEquals(SmsLogStatus.SENT, capturedLog.getStatus()); + assertEquals("+255700000000", capturedLog.getRecipient()); + assertEquals("wamid.123", capturedLog.getProviderMessageId()); + assertTrue(capturedLog.getContent().contains("farm_alert/en")); + assertTrue(capturedLog.getContent().contains("Feed is low")); + assertNotNull(result); + // Not a system connector, so no wallet debit is expected either way. + verifyNoInteractions(walletService); + } + + @Test + void queueWhatsAppTemplate_shouldSaveFailedLogWhenSenderFails() { + SmsConnector connector = new SmsConnector(); + connector.setProvider("WHATSAPP"); + connector.setCreatedBy(testUser); + + WhatsAppTemplate template = new WhatsAppTemplate(); + template.setStatus("APPROVED"); + + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); + when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + .thenReturn(Optional.of(connector)); + when(whatsAppTemplateRepository.findByNameAndLanguageAndConnectorAndCreatedBy("farm_alert", "en", + connector, testUser)).thenReturn(Optional.of(template)); + when(whatsAppSender.sendTemplate(eq(connector), eq("+255700000000"), eq("farm_alert"), eq("en"), any())) + .thenReturn(SmsSendResult.failure("Meta rejected the template", "131047", Map.of())); + when(logRepository.save(any(SmsLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + SmsLog result = notificationService.queueWhatsAppTemplate(validTemplatePayload(), "testuser"); + + verify(logRepository).save(smsLogCaptor.capture()); + SmsLog capturedLog = smsLogCaptor.getValue(); + assertEquals(SmsLogStatus.FAILED, capturedLog.getStatus()); + assertEquals("Meta rejected the template", capturedLog.getError()); + assertNotNull(result); + verifyNoInteractions(walletService); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java index 0872a0b..2e492da 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java @@ -9,6 +9,7 @@ import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppRelayDeliveryRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppMediaService; import org.junit.jupiter.api.BeforeEach; @@ -47,6 +48,9 @@ class WhatsAppWebhookControllerTest { @Mock private WhatsAppRelayDeliveryRepository relayDeliveryRepository; + @Mock + private WhatsAppTemplateRepository templateRepository; + @Mock private WhatsAppMediaService mediaService; @@ -60,7 +64,7 @@ void setUpDefaults() { } private WhatsAppWebhookController controller() { - return new WhatsAppWebhookController(configRepository, smsLogRepository, inboxMessageRepository, relayDeliveryRepository, mediaService, objectMapper); + return new WhatsAppWebhookController(configRepository, smsLogRepository, inboxMessageRepository, relayDeliveryRepository, templateRepository, mediaService, objectMapper); } private WhatsAppWebhookConfig activeConfig() { @@ -146,6 +150,55 @@ void receiveGeneratedCallback_shouldAccept_whenPayloadHasNoPhoneNumberId() { verify(configRepository).save(config); } + @Test + void receiveGeneratedCallback_shouldUpdateTemplateStatusAndStillRelay_whenEventIsTemplateStatusUpdate() { + WhatsAppWebhookConfig config = activeConfig(); + when(configRepository.findByCallbackTokenAndActiveTrue("callback-token")).thenReturn(Optional.of(config)); + + com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate template = + new com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate(); + template.setMetaTemplateId("META_TEMPLATE_ID"); + template.setStatus("PENDING"); + when(templateRepository.findFirstByMetaTemplateId("META_TEMPLATE_ID")).thenReturn(Optional.of(template)); + + String payload = "{\"entry\":[{\"changes\":[{\"field\":\"message_template_status_update\",\"value\":{" + + "\"event\":\"APPROVED\",\"message_template_id\":\"META_TEMPLATE_ID\"," + + "\"message_template_name\":\"farm_alert\",\"message_template_language\":\"en\"}}]}]}"; + + ResponseEntity response = controller().receiveGeneratedCallback("callback-token", payload, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals("APPROVED", template.getStatus()); + verify(templateRepository).save(template); + + // Meta template-status events carry no phone_number_id, so relay must still fire via the + // callback-token-scoped config rather than being skipped for lack of a phone match. + ArgumentCaptor relayCaptor = ArgumentCaptor.forClass(WhatsAppRelayDelivery.class); + verify(relayDeliveryRepository).save(relayCaptor.capture()); + assertEquals(config, relayCaptor.getValue().getConfig()); + } + + @Test + void receiveGeneratedCallback_shouldFallBackToNameAndLanguage_whenTemplateIdMissing() { + WhatsAppWebhookConfig config = activeConfig(); + when(configRepository.findByCallbackTokenAndActiveTrue("callback-token")).thenReturn(Optional.of(config)); + + com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate template = + new com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate(); + template.setName("farm_alert"); + template.setLanguage("en"); + template.setStatus("PENDING"); + when(templateRepository.findFirstByNameAndLanguage("farm_alert", "en")).thenReturn(Optional.of(template)); + + String payload = "{\"entry\":[{\"changes\":[{\"field\":\"message_template_status_update\",\"value\":{" + + "\"event\":\"REJECTED\",\"message_template_name\":\"farm_alert\",\"message_template_language\":\"en\"}}]}]}"; + + controller().receiveGeneratedCallback("callback-token", payload, null); + + assertEquals("REJECTED", template.getStatus()); + verify(templateRepository).save(template); + } + @Test void receiveGeneratedCallback_shouldAccept_whenPhoneNumberIdDiffersFromConfig() { WhatsAppWebhookConfig config = activeConfig(); 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 new file mode 100644 index 0000000..307de41 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateSyncServiceTest.java @@ -0,0 +1,136 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +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.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WhatsAppTemplateSyncServiceTest { + + @Mock + private WhatsAppTemplateRepository repository; + + @Mock + private SmsConnectorRepository connectorRepository; + + @Mock + private CurrentUserResolver currentUserResolver; + + @Mock + private RestTemplate restTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private WhatsAppTemplateSyncService service; + + private User testUser; + private SmsConnector connector; + + @BeforeEach + void setUp() { + service = new WhatsAppTemplateSyncService(repository, connectorRepository, currentUserResolver, restTemplate, + objectMapper); + + testUser = new User(); + testUser.setId(UUID.randomUUID()); + + 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.setCreatedBy(testUser); + } + + private Map metaResponse(List> templates) { + return Map.of("data", templates, "paging", Map.of()); + } + + @Test + void syncForCurrentUser_shouldThrowWhenNoConnector() { + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(testUser)); + when(connectorRepository.findAllByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + .thenReturn(List.of()); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, service::syncForCurrentUser); + 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)); + + ResponseStatusException ex = assertThrows(ResponseStatusException.class, service::syncForCurrentUser); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode()); + assertTrue(ex.getReason().contains("businessAccountId")); + } + + @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}}"))); + + 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)); + + WhatsAppTemplate staleTemplate = new WhatsAppTemplate(); + staleTemplate.setMetaTemplateId("META_ID_STALE"); + staleTemplate.setStatus("APPROVED"); + staleTemplate.setConnector(connector); + staleTemplate.setCreatedBy(testUser); + + when(repository.findByConnectorAndCreatedBy(connector, testUser)).thenReturn(List.of(staleTemplate)); + when(repository.save(any(WhatsAppTemplate.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + List upserted = service.sync(connector); + + 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")); + + 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()); + } +}