From 800be5b65e791fed8946aa938f2614b5f8917295 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 21 Oct 2026 21:46:53 +0300 Subject: [PATCH 1/2] release: Add multi channel support --- README.md | 19 + .../core/config/LegacySchemaCleanup.java | 36 ++ .../settings/DataRetentionPolicy.java | 42 ++ .../core/entities/sms/SmsConnector.java | 4 +- .../flextuma/core/entities/sms/SmsLog.java | 23 ++ .../entities/whatsapp/WhatsAppMetaApp.java | 71 ++++ .../whatsapp/WhatsAppRelayDelivery.java | 4 + .../whatsapp/WhatsAppWebhookConfig.java | 33 +- .../whatsapp/WhatsAppWebhookEvent.java | 65 +++ .../flextuma/core/helpers/HmacUtil.java | 3 +- .../DataRetentionPolicyRepository.java | 17 + .../repositories/SmsConnectorRepository.java | 18 +- .../core/repositories/SmsLogRepository.java | 2 +- .../WhatsAppMetaAppRepository.java | 19 + .../WhatsAppRelayDeliveryRepository.java | 8 +- .../WhatsAppWebhookConfigRepository.java | 17 +- .../WhatsAppWebhookEventRepository.java | 20 + .../flextuma/core/senders/WhatsAppSender.java | 4 +- .../services/MessageRetryPolicy.java | 87 ++++ .../services/NotificationService.java | 49 ++- .../services/SmsDispatchWorker.java | 86 ++-- .../DataRetentionPolicyController.java | 30 ++ .../dtos/DataRetentionPolicyDTO.java | 16 + .../dtos/DataRetentionPolicyUpdateDTO.java | 5 + .../services/DataRetentionPolicyService.java | 81 ++++ .../modules/sms/services/SmsLogService.java | 9 + .../WhatsAppMetaAppController.java | 16 + .../WhatsAppWebhookController.java | 388 ++++++------------ .../WhatsAppWebhookEventController.java | 16 + .../WhatsAppEventRetentionPolicy.java | 46 +++ .../WhatsAppEventRetentionScheduler.java | 49 +++ .../services/WhatsAppMediaService.java | 6 +- .../services/WhatsAppMetaAppService.java | 84 ++++ .../services/WhatsAppRelayDispatchWorker.java | 126 ++++-- .../WhatsAppWebhookConfigService.java | 26 ++ .../services/WhatsAppWebhookEventService.java | 114 +++++ .../WhatsAppInboundMessageIngestor.java | 109 +++++ .../services/webhook/WhatsAppPayloads.java | 100 +++++ .../webhook/WhatsAppSharedWebhookRouter.java | 116 ++++++ .../webhook/WhatsAppStatusUpdater.java | 111 +++++ .../webhook/WhatsAppWebhookEventRecorder.java | 116 ++++++ .../webhook/WhatsAppWebhookProcessor.java | 98 +++++ src/main/resources/application.properties | 14 + .../services/NotificationServiceTest.java | 36 +- .../services/SmsDispatchWorkerTest.java | 142 ++++++- .../DataRetentionPolicyServiceTest.java | 110 +++++ .../sms/services/SmsLogServiceTest.java | 6 + .../WhatsAppSharedWebhookControllerTest.java | 243 +++++++++++ .../WhatsAppWebhookControllerTest.java | 102 ++++- .../WhatsAppEventRetentionPolicyTest.java | 45 ++ .../WhatsAppEventRetentionSchedulerTest.java | 81 ++++ .../services/WhatsAppMediaServiceTest.java | 12 +- .../services/WhatsAppMetaAppServiceTest.java | 87 ++++ .../WhatsAppRelayDispatchWorkerTest.java | 148 +++++-- .../WhatsAppWebhookConfigServiceTest.java | 26 ++ 55 files changed, 2897 insertions(+), 444 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/config/LegacySchemaCleanup.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/settings/DataRetentionPolicy.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppMetaApp.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookEvent.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/DataRetentionPolicyRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppMetaAppRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookEventRepository.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/notification/services/MessageRetryPolicy.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/retention/controllers/DataRetentionPolicyController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/retention/dtos/DataRetentionPolicyDTO.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/retention/dtos/DataRetentionPolicyUpdateDTO.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/retention/services/DataRetentionPolicyService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppMetaAppController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookEventController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionPolicy.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionScheduler.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMetaAppService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookEventService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppInboundMessageIngestor.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppPayloads.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppSharedWebhookRouter.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppStatusUpdater.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppWebhookEventRecorder.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppWebhookProcessor.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/retention/services/DataRetentionPolicyServiceTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppSharedWebhookControllerTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionPolicyTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionSchedulerTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMetaAppServiceTest.java diff --git a/README.md b/README.md index 9820910..7774c55 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,25 @@ To deliver inbound WhatsApp messages and status events into a user's system, cre Set `FLEXTUMA_PUBLIC_BASE_URL` to Flextuma's public HTTPS origin before creating configurations. On creation, Flextuma generates and persists a random `verifyToken` and a unique `metaCallbackUrl`. Copy these two returned values directly into WhatsApp Cloud's callback URL and verify-token fields; users do not need to create or manage secrets for Meta verification. The generated URL binds Meta's request to that user's configuration, and Flextuma also checks the configured `phoneNumberId`. Flextuma validates Meta's `X-Hub-Signature-256` whenever `appSecret` is set, then forwards the original JSON to the owning user's `callbackUrl`. When `signingSecret` is supplied, the forwarded request includes `X-Flextuma-Signature-256: sha256=` and `X-Flextuma-Event: whatsapp`. Callback URLs must be HTTPS. The callback endpoint must return quickly with a 2xx response; failed relays are logged and do not trigger a Meta retry, preventing duplicate downstream processing. +#### Multiple WhatsApp numbers and shared callback URLs + +An account can hold several WhatsApp connectors, one per Meta phone number (`senderId`), all on the same Graph API `url`; connectors are unique per provider, URL, sender ID and owner. When a send does not pass `connectorId`, the owner's active connector with `isDefault: true` is used (the oldest one wins if several are flagged). Create one `/api/whatsappWebhookConfigs` record per phone number and link each to its connector. + +Meta sets the webhook callback URL **once per Meta app** (App Dashboard → WhatsApp → Configuration). Every WhatsApp Business Account subscribed to that app (`POST /{WABA_ID}/subscribed_apps`), and every phone number in it, delivers to that one URL, signed with the app secret. Optional overrides can be set per WABA (`override_callback_uri` on `subscribed_apps`) or per phone number (`webhook_configuration.override_callback_uri` on `POST /{PHONE_NUMBER_ID}`). Meta checks the phone-number override first, then the WABA override, then the app URL. To support this, create a shared Meta app: + +```json +POST /api/whatsappMetaApps +{ "name": "Main business app", "metaAppId": "1234567890", "appSecret": "Meta App Secret", "verifyToken": "optional" } +``` + +The response includes a generated `metaCallbackUrl` (`/api/webhooks/whatsapp/apps/{token}`). Paste it, with the verify token, into the Meta app once. Then link each phone number's webhook configuration to the app: + +```json +{ "phoneNumberId": "META_PHONE_NUMBER_ID", "connector": { "id": "…" }, "metaApp": { "id": "…" }, "businessAccountId": "WABA_ID" } +``` + +Events on the shared URL are checked against the app secret. Each change is then routed to the configuration whose `phoneNumberId` matches `value.metadata.phone_number_id`. WABA-level changes carry no phone number (template status, account updates); they go to every linked configuration whose `businessAccountId` matches `entry[].id`, or to the app's only configuration if there is just one. Only configurations linked to that app can receive its events. Each configuration keeps its own per-number `metaCallbackUrl` for use as a phone-number override. When a linked configuration has no `appSecret` of its own, the app's secret validates that URL too. Deleting a Meta app detaches its configurations, which keep working on their own URLs. + ### Planned: Flextuma-managed Meta Tech Provider onboarding The configuration above is the current **bring-your-own-Meta** integration: customers supply their Cloud API credentials and phone-number ID. The planned Tech Provider mode will offer a branded **Connect WhatsApp** flow through Meta Embedded Signup, so customers do not manually handle access tokens, app secrets, callback URLs, or verification tokens. diff --git a/src/main/java/com/flexcodelabs/flextuma/core/config/LegacySchemaCleanup.java b/src/main/java/com/flexcodelabs/flextuma/core/config/LegacySchemaCleanup.java new file mode 100644 index 0000000..6580778 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/config/LegacySchemaCleanup.java @@ -0,0 +1,36 @@ +package com.flexcodelabs.flextuma.core.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.CommandLineRunner; +import org.springframework.core.annotation.Order; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Drops schema objects that {@code spring.jpa.hibernate.ddl-auto=update} adds but never removes. + * Each statement is idempotent, so this is safe on every boot. + */ +@Slf4j +@Component +@Order(0) +@RequiredArgsConstructor +public class LegacySchemaCleanup implements CommandLineRunner { + + // Replaced by unique_provider_url_sender: the old key blocked a second WhatsApp connector + // (another phone number) on the same Graph API URL for one account. + private static final String DROP_SMS_CONNECTOR_PROVIDER_URL_KEY = + "ALTER TABLE smsconnector DROP CONSTRAINT IF EXISTS unique_provider_url"; + + private final JdbcTemplate jdbcTemplate; + + @Override + public void run(String... args) { + try { + jdbcTemplate.execute(DROP_SMS_CONNECTOR_PROVIDER_URL_KEY); + } catch (DataAccessException e) { + log.warn("Legacy schema cleanup skipped: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/settings/DataRetentionPolicy.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/settings/DataRetentionPolicy.java new file mode 100644 index 0000000..46e211b --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/settings/DataRetentionPolicy.java @@ -0,0 +1,42 @@ +package com.flexcodelabs.flextuma.core.entities.settings; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * An account's data retention choices. Scoped to the organisation when the user has one (shared + * by every member), otherwise to the individual user -- the same tenant boundary + * TenantAwareSpecification uses. Exactly one of organisation/owner is set. + */ +@Entity +@Table(name = "data_retention_policy") +@Getter +@Setter +@NoArgsConstructor +public class DataRetentionPolicy extends BaseEntity { + + @JsonIgnore + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organisation", unique = true) + private Organisation organisation; + + @JsonIgnore + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "owner", unique = true) + private User owner; + + /** Days to keep the WhatsApp event log. Null = platform default. */ + @Column(name = "whatsapp_event_retention_days") + private Integer whatsappEventRetentionDays; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java index 06fdf30..3e4150c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java @@ -9,8 +9,10 @@ import lombok.*; @Entity +// senderid is part of the key so one account can hold several connectors on the same provider +// endpoint -- e.g. one WhatsApp connector per Meta phone number, all on graph.facebook.com. @Table(name = "smsconnector", uniqueConstraints = { - @UniqueConstraint(name = "unique_provider_url", columnNames = { "provider", "url", "creator" }) + @UniqueConstraint(name = "unique_provider_url_sender", columnNames = { "provider", "url", "senderid", "creator" }) }) @Getter @Setter diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java index 015f154..3c7e0cc 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsLog.java @@ -2,9 +2,11 @@ import java.util.Map; +import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.flexcodelabs.flextuma.core.entities.base.Owner; @@ -52,9 +54,23 @@ public class SmsLog extends Owner { @Enumerated(EnumType.STRING) private SmsLogStatus status; + /** Total re-sends of this message, automatic backoff retries and manual resends alike. */ @Column(name = "retries", nullable = false) private int retries = 0; + /** Send attempts in the current delivery cycle; a manual resend starts a fresh cycle. */ + @ColumnDefault("0") + @Column(name = "delivery_attempts", nullable = false) + private int deliveryAttempts = 0; + + /** When the next automatic retry is due; null when none is scheduled. */ + @Column(name = "next_retry_at", nullable = true) + private java.time.LocalDateTime nextRetryAt; + + /** First failure of the current delivery cycle; retries stop once the retry window from here runs out. */ + @Column(name = "retry_window_started_at", nullable = true) + private java.time.LocalDateTime retryWindowStartedAt; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "connector", nullable = true) private SmsConnector connector; @@ -75,4 +91,11 @@ public class SmsLog extends Owner { @Column(name = "scheduled_at", nullable = true) private java.time.LocalDateTime scheduledAt; + /** For WhatsApp template sends: {name, language, components}, so a retry resends the + * template itself rather than the human-readable rendering stored in {@link #content}. */ + @JsonIgnore + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "whatsapp_template", nullable = true) + private Map whatsappTemplate; + } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppMetaApp.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppMetaApp.java new file mode 100644 index 0000000..a71feb7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppMetaApp.java @@ -0,0 +1,71 @@ +package com.flexcodelabs.flextuma.core.entities.whatsapp; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.flexcodelabs.flextuma.core.entities.base.Owner; +import com.flexcodelabs.flextuma.core.helpers.MaskingUtil; +import com.flexcodelabs.flextuma.core.security.EncryptedStringConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * One Meta (Facebook) business app. Meta configures the WhatsApp webhook callback URL once per + * app (App Dashboard → WhatsApp → Configuration), and every WhatsApp Business Account subscribed + * to that app -- with all of its phone numbers -- posts to that single URL, signed with the app's + * secret. Linking several {@link WhatsAppWebhookConfig}s to one of these gives them a shared + * {@link #metaCallbackUrl}; inbound events are routed to the right config by + * {@code metadata.phone_number_id} (or the WABA id in {@code entry[].id} for account-level events). + */ +@Entity +@Table(name = "whatsapp_meta_app") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class WhatsAppMetaApp extends Owner { + public static final String PLURAL = "whatsappMetaApps"; + public static final String NAME_PLURAL = "WhatsApp Meta Apps"; + public static final String NAME_SINGULAR = "WhatsApp Meta App"; + + @NotBlank + @Column(name = "name", nullable = false) + private String name; + + /** Meta App ID from the App Dashboard; informational, helps users match this to Meta. */ + @Column(name = "meta_app_id") + private String metaAppId; + + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @Column(name = "verify_token", nullable = false) + @Convert(converter = EncryptedStringConverter.class) + private String verifyToken; + + /** Meta App Secret used to validate X-Hub-Signature-256 for every event on the shared URL. */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + @Column(name = "app_secret") + @Convert(converter = EncryptedStringConverter.class) + private String appSecret; + + @JsonIgnore + @Column(name = "callback_token", nullable = false, unique = true) + private String callbackToken; + + /** The shared Meta callback URL generated by Flextuma; paste this once into the Meta app. */ + @Column(name = "meta_callback_url", nullable = false, columnDefinition = "TEXT") + private String metaCallbackUrl; + + @Column(name = "last_verified_at") + private LocalDateTime lastVerifiedAt; + + @Column(name = "last_event_at") + private LocalDateTime lastEventAt; + + @JsonProperty("verifyToken") public String getMaskedVerifyToken() { return MaskingUtil.mask(verifyToken); } + @JsonProperty("appSecret") public String getMaskedAppSecret() { return MaskingUtil.mask(appSecret); } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppRelayDelivery.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppRelayDelivery.java index aea0a9f..cb7d3bb 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppRelayDelivery.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppRelayDelivery.java @@ -43,6 +43,10 @@ public class WhatsAppRelayDelivery extends Owner { @Column(nullable = false) private int retries = 0; + /** When the next automatic retry is due; null when none is scheduled. */ + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + @Column(name = "last_attempt_at") private LocalDateTime lastAttemptAt; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookConfig.java index cc34755..9905901 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookConfig.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.flexcodelabs.flextuma.core.entities.base.Owner; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.helpers.MaskingUtil; @@ -37,15 +38,40 @@ public class WhatsAppWebhookConfig extends Owner { @Column(name = "phone_number_id", nullable = false) private String phoneNumberId; + /** Optional human label for this number, e.g. "Support line". */ + @Column(name = "label") + private String label; + + /** The number customers see (e.g. "+255 700 000 000"). Entered by the user, or filled in from + * value.metadata.display_phone_number the first time Meta delivers an event for this number. */ + @Column(name = "display_phone_number") + private String displayPhoneNumber; + /** * The WHATSAPP-provider SmsConnector whose Meta access token is used to download inbound * media for this webhook. Optional: when unset, WhatsAppMediaService falls back to the - * owner's first active WhatsApp connector, which is ambiguous if they have more than one. + * owner's preferred WhatsApp connector (isDefault first) -- set it when the owner has several. */ @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "connector") private SmsConnector connector; + /** + * Optional shared Meta app. When set, Meta can deliver this phone number's events to the + * app's shared callback URL (one URL for every number in the same Meta business app), and + * the app's secret validates them. The config's own metaCallbackUrl keeps working too, e.g. + * as a phone-number-level override_callback_uri. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meta_app") + @JsonIgnoreProperties({ "createdBy", "updatedBy" }) + private WhatsAppMetaApp metaApp; + + /** WhatsApp Business Account (WABA) id. Routes WABA-level events on a shared callback URL + * (template status, account updates) that carry no phone_number_id; Meta sends it as entry[].id. */ + @Column(name = "business_account_id") + private String businessAccountId; + @Column(name = "callback_url", columnDefinition = "TEXT") private String callbackUrl; @@ -82,6 +108,11 @@ public class WhatsAppWebhookConfig extends Owner { @Column(name = "last_event_at") private LocalDateTime lastEventAt; + /** Days to keep this webhook's event log. Null or 0 = inherit the account's setting (or the + * platform default); values above the platform maximum are capped (see WhatsAppEventRetentionPolicy). */ + @Column(name = "event_retention_days") + private Integer eventRetentionDays; + @JsonProperty("verifyToken") public String getMaskedVerifyToken() { return MaskingUtil.mask(verifyToken); } @JsonProperty("signingSecret") public String getMaskedSigningSecret() { return MaskingUtil.mask(signingSecret); } @JsonProperty("appSecret") public String getMaskedAppSecret() { return MaskingUtil.mask(appSecret); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookEvent.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookEvent.java new file mode 100644 index 0000000..22f82e2 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/whatsapp/WhatsAppWebhookEvent.java @@ -0,0 +1,65 @@ +package com.flexcodelabs.flextuma.core.entities.whatsapp; + +import com.flexcodelabs.flextuma.core.entities.base.Owner; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * One signature-valid change from a Meta webhook delivery (a single delivery can carry several), + * recorded by {@code WhatsAppWebhookController} for every event type -- messages, statuses, + * template updates, account alerts, etc. -- whether or not the config relays to a callback URL. + * Written only by the webhook, so {@link #ADD}/{@link #UPDATE}/{@link #DELETE} require a + * permission no tenant role is granted (see {@code WhatsAppWebhookEventService}). + */ +@Entity +@Table(name = "whatsapp_webhook_event", indexes = { + @Index(name = "idx_whatsapp_webhook_event_received_at", columnList = "received_at") +}) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor +public class WhatsAppWebhookEvent extends Owner { + public static final String PLURAL = "whatsAppWebhookEvents"; + public static final String NAME_PLURAL = "WhatsApp Events"; + public static final String NAME_SINGULAR = "WhatsApp Event"; + public static final String READ = "ALL"; + public static final String ADD = "ADD_WHATSAPP_WEBHOOK_EVENTS"; + public static final String DELETE = "DELETE_WHATSAPP_WEBHOOK_EVENTS"; + public static final String UPDATE = "UPDATE_WHATSAPP_WEBHOOK_EVENTS"; + + /** Cascades at the DB level: the event log belongs to its config, and BaseService#delete removes + * configs with a native DELETE that would otherwise be blocked by this FK. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "config", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private WhatsAppWebhookConfig config; + + /** Meta's change field, e.g. "messages", "message_template_status_update", "account_alerts". */ + @Column(nullable = false) + private String field; + + /** Derived kind, e.g. "message.received", "status.delivered", "template.APPROVED". */ + @Column(name = "event_type", nullable = false) + private String eventType; + + @Column(length = 500) + private String summary; + + /** The raw change object ({"field": ..., "value": ...}) as JSON. */ + @Column(columnDefinition = "TEXT", nullable = false) + private String payload; + + @Column(name = "received_at", nullable = false) + private LocalDateTime receivedAt; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/helpers/HmacUtil.java b/src/main/java/com/flexcodelabs/flextuma/core/helpers/HmacUtil.java index f00a375..30953aa 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/helpers/HmacUtil.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/helpers/HmacUtil.java @@ -3,13 +3,14 @@ import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; public final class HmacUtil { private HmacUtil() { } - public static String sha256Hex(String payload, String secret) throws Exception { + public static String sha256Hex(String payload, String secret) throws GeneralSecurityException { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); StringBuilder result = new StringBuilder(); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/DataRetentionPolicyRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/DataRetentionPolicyRepository.java new file mode 100644 index 0000000..84167eb --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/DataRetentionPolicyRepository.java @@ -0,0 +1,17 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.settings.DataRetentionPolicy; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DataRetentionPolicyRepository extends BaseRepository { + + Optional findByOrganisation(Organisation organisation); + + Optional findByOwner(User owner); +} 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 fc172f5..ffe0995 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsConnectorRepository.java @@ -5,6 +5,8 @@ import java.util.UUID; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import com.flexcodelabs.flextuma.core.entities.auth.User; @@ -14,11 +16,19 @@ public interface SmsConnectorRepository extends BaseRepository, JpaSpecificationExecutor { - Optional findByCreatedByAndProviderAndActiveTrue(User createdBy, String provider); + /** A tenant may own several active connectors per provider (e.g. one per WhatsApp phone + * number), so this lists them all: connectors flagged isDefault first, then oldest first. */ + @Query("SELECT c FROM SmsConnector c WHERE c.createdBy = :createdBy AND c.provider = :provider AND c.active = true" + + " ORDER BY CASE WHEN c.isDefault = true THEN 0 ELSE 1 END, c.created ASC") + List findActiveByCreatedByAndProviderPreferringDefault(@Param("createdBy") User createdBy, + @Param("provider") 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. */ + /** The connector used when a caller doesn't pick one explicitly (see the ordering above). */ + default Optional findPreferredActiveConnector(User createdBy, String provider) { + return findActiveByCreatedByAndProviderPreferringDefault(createdBy, provider).stream().findFirst(); + } + + /** Used by WhatsAppTemplateSyncService to sync every WABA a tenant has connected. */ List findAllByCreatedByAndProviderAndActiveTrue(User createdBy, String provider); Optional findFirstByCreatedByAndActiveTrue(User createdBy); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java index 6e32712..4ac3b0e 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/SmsLogRepository.java @@ -25,7 +25,7 @@ public interface SmsLogRepository extends BaseRepository, List findTop50ByStatusAndProviderMessageIdIsNotNullOrderByCreatedAsc(SmsLogStatus status); - @org.springframework.data.jpa.repository.Query("SELECT s FROM SmsLog s WHERE s.status = :status AND (s.scheduledAt IS NULL OR s.scheduledAt <= :now) ORDER BY s.created ASC") + @org.springframework.data.jpa.repository.Query("SELECT s FROM SmsLog s WHERE s.status = :status AND (s.scheduledAt IS NULL OR s.scheduledAt <= :now) AND (s.nextRetryAt IS NULL OR s.nextRetryAt <= :now) ORDER BY s.created ASC") List findDueMessages( @org.springframework.data.repository.query.Param("status") SmsLogStatus status, @org.springframework.data.repository.query.Param("now") java.time.LocalDateTime now, diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppMetaAppRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppMetaAppRepository.java new file mode 100644 index 0000000..d1eb35d --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppMetaAppRepository.java @@ -0,0 +1,19 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppMetaApp; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface WhatsAppMetaAppRepository extends BaseRepository, JpaSpecificationExecutor { + + @Query("SELECT a FROM WhatsAppMetaApp a JOIN FETCH a.createdBy WHERE a.callbackToken = :callbackToken AND a.active = true") + Optional findByCallbackTokenAndActiveTrue(@Param("callbackToken") String callbackToken); + + Optional findByIdAndActiveTrue(UUID id); +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppRelayDeliveryRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppRelayDeliveryRepository.java index 2b2f70e..48a9d8f 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppRelayDeliveryRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppRelayDeliveryRepository.java @@ -2,12 +2,14 @@ import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppRelayDelivery; import com.flexcodelabs.flextuma.core.enums.WhatsAppRelayStatus; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; @@ -15,7 +17,11 @@ public interface WhatsAppRelayDeliveryRepository extends BaseRepository, JpaSpecificationExecutor { - List findTop50ByStatusOrderByCreatedAsc(WhatsAppRelayStatus status); + @Query("SELECT d FROM WhatsAppRelayDelivery d WHERE d.status = :status" + + " AND (d.nextRetryAt IS NULL OR d.nextRetryAt <= :now) ORDER BY d.created ASC") + List findDueDeliveries(@Param("status") WhatsAppRelayStatus status, + @Param("now") LocalDateTime now, + Pageable pageable); @Modifying @Query("UPDATE WhatsAppRelayDelivery d SET d.status = :processing WHERE d.id = :id AND d.status = :pending") diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookConfigRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookConfigRepository.java index 029ae13..db7f1d4 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookConfigRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookConfigRepository.java @@ -2,9 +2,11 @@ import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -16,12 +18,21 @@ public interface WhatsAppWebhookConfigRepository extends BaseRepository findByPhoneNumberIdAndActiveTrue(@Param("phoneNumberId") String phoneNumberId); Optional findByVerifyTokenAndActiveTrue(String verifyToken); - @Query("SELECT c FROM WhatsAppWebhookConfig c JOIN FETCH c.createdBy LEFT JOIN FETCH c.connector WHERE c.callbackToken = :callbackToken AND c.active = true") + @Query("SELECT c FROM WhatsAppWebhookConfig c JOIN FETCH c.createdBy LEFT JOIN FETCH c.connector LEFT JOIN FETCH c.metaApp WHERE c.callbackToken = :callbackToken AND c.active = true") Optional findByCallbackTokenAndActiveTrue(@Param("callbackToken") String callbackToken); + + /** Every active config sharing a Meta app's callback URL, with the same eager fetches as above. */ + @Query("SELECT c FROM WhatsAppWebhookConfig c JOIN FETCH c.createdBy LEFT JOIN FETCH c.connector WHERE c.metaApp.id = :metaAppId AND c.active = true") + List findAllActiveByMetaAppId(@Param("metaAppId") UUID metaAppId); + + @Modifying + @Query("UPDATE WhatsAppWebhookConfig c SET c.metaApp = null WHERE c.metaApp.id = :metaAppId") + int detachMetaApp(@Param("metaAppId") UUID metaAppId); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookEventRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookEventRepository.java new file mode 100644 index 0000000..f0332e6 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppWebhookEventRepository.java @@ -0,0 +1,20 @@ +package com.flexcodelabs.flextuma.core.repositories; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookEvent; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Repository +public interface WhatsAppWebhookEventRepository extends BaseRepository, + JpaSpecificationExecutor { + + @Modifying + @Query("DELETE FROM WhatsAppWebhookEvent e WHERE e.config.id = :configId AND e.receivedAt < :cutoff") + int deleteByConfigIdAndReceivedAtBefore(@Param("configId") UUID configId, @Param("cutoff") LocalDateTime cutoff); +} 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 20f5924..5a22bd9 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/senders/WhatsAppSender.java @@ -55,7 +55,7 @@ public SmsSendResult sendSms(SmsConnector config, String to, String message) { String.valueOf(response.getStatusCode().value()), responseBody); } catch (Exception e) { return SmsSendResult.failure("Failed to send WhatsApp message: " + e.getMessage(), "SEND_ERROR", - Map.of("error", e.getMessage())); + Map.of("error", String.valueOf(e.getMessage()))); } } @@ -97,7 +97,7 @@ public SmsSendResult sendTemplate(SmsConnector config, String to, String templat 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())); + Map.of("error", String.valueOf(e.getMessage()))); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/MessageRetryPolicy.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/MessageRetryPolicy.java new file mode 100644 index 0000000..896d1c6 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/MessageRetryPolicy.java @@ -0,0 +1,87 @@ +package com.flexcodelabs.flextuma.modules.notification.services; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; +import com.flexcodelabs.flextuma.core.services.SmsSendResult; + +import lombok.extern.slf4j.Slf4j; + +/** + * Retry semantics modelled on Kafka retry topics: each failed attempt is re-queued with an + * exponentially growing delay (initial × multiplier^(attempt-1), capped at max-interval). Every + * error is retried -- a 4xx today may succeed once the receiving side fixes its config -- until + * the retry window (12h by default, measured from the first failure) runs out, after which the + * message is dead-lettered as FAILED. + */ +@Slf4j +@Component +public class MessageRetryPolicy { + + private final long initialIntervalMs; + private final double multiplier; + private final long maxIntervalMs; + private final Duration retryWindow; + + public MessageRetryPolicy( + @Value("${flextuma.messaging.retry.initial-interval-ms:10000}") long initialIntervalMs, + @Value("${flextuma.messaging.retry.multiplier:2.0}") double multiplier, + @Value("${flextuma.messaging.retry.max-interval-ms:3600000}") long maxIntervalMs, + @Value("${flextuma.messaging.retry.window-ms:43200000}") long retryWindowMs) { + this.initialIntervalMs = Math.max(0, initialIntervalMs); + this.multiplier = Math.max(1.0, multiplier); + this.maxIntervalMs = Math.max(this.initialIntervalMs, maxIntervalMs); + this.retryWindow = Duration.ofMillis(Math.max(0, retryWindowMs)); + } + + /** Delay before the retry that follows the given (1-based) failed attempt. */ + public Duration backoff(int failedAttempt) { + double delay = initialIntervalMs * Math.pow(multiplier, Math.max(0, failedAttempt - 1)); + return Duration.ofMillis((long) Math.min(delay, maxIntervalMs)); + } + + /** Delay before the next retry, or empty once the window opened at {@code windowStart} has run out. */ + public Optional nextRetryDelay(int failedAttempt, LocalDateTime windowStart) { + if (windowStart != null && !LocalDateTime.now().isBefore(windowStart.plus(retryWindow))) { + return Optional.empty(); + } + return Optional.of(backoff(failedAttempt)); + } + + /** Records a failed attempt on the log: schedules the next retry, or dead-letters it as FAILED. */ + public void onFailure(SmsLog smsLog, SmsSendResult result) { + int attempt = smsLog.getDeliveryAttempts(); + smsLog.setError(result.getMessage()); + smsLog.setProviderResponse(result.getProviderResponse()); + if (smsLog.getRetryWindowStartedAt() == null) { + smsLog.setRetryWindowStartedAt(LocalDateTime.now()); + } + + Optional delay = nextRetryDelay(attempt, smsLog.getRetryWindowStartedAt()); + if (delay.isPresent()) { + smsLog.setStatus(SmsLogStatus.PENDING); + smsLog.setRetries(smsLog.getRetries() + 1); + smsLog.setNextRetryAt(LocalDateTime.now().plus(delay.get())); + log.warn("SmsLog [{}] attempt {} failed, retrying in {}s: {}", smsLog.getId(), attempt, + delay.get().toSeconds(), result.getMessage()); + return; + } + + deadLetter(smsLog, result.getMessage()); + log.warn("SmsLog [{}] FAILED after {} attempt(s) over the {}h retry window: {}", smsLog.getId(), attempt, + retryWindow.toHours(), result.getMessage()); + } + + /** Marks the log FAILED without scheduling another attempt. */ + public void deadLetter(SmsLog smsLog, String error) { + smsLog.setStatus(SmsLogStatus.FAILED); + smsLog.setError(error); + smsLog.setNextRetryAt(null); + } +} 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 bee2230..559d96e 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 @@ -54,6 +54,7 @@ public class NotificationService { private final EntityAssociationReferenceResolver entityAssociationReferenceResolver; private final WhatsAppTemplateRepository whatsAppTemplateRepository; private final WhatsAppSender whatsAppSender; + private final MessageRetryPolicy retryPolicy; @Value("${flextuma.sms.price-per-segment:1.0}") private BigDecimal pricePerSegment; @@ -106,10 +107,9 @@ public SmsLog queueRawSms(Map payload, String username) { } /** 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. */ + * the first attempt is made synchronously so the caller sees the outcome immediately. The + * template payload is stored on the log, so a transient failure falls through to + * SmsDispatchWorker's backoff retries (and a manual resend) with the real template. */ @Transactional public SmsLog queueWhatsAppTemplate(Map payload, String username) { User currentUser = getUser(username); @@ -141,26 +141,25 @@ public SmsLog queueWhatsAppTemplate(Map payload, String username components); String renderedContent = renderTemplateForLog(templateName, templateLanguage, components); + Map templatePayload = new java.util.LinkedHashMap<>(); + templatePayload.put("name", templateName); + templatePayload.put("language", templateLanguage); + templatePayload.put("components", 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.setWhatsappTemplate(templatePayload); + log.setDeliveryAttempts(1); 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); + if (result.isSuccess()) { + log.setStatus(SmsLogStatus.SENT); + log.setProviderResponse(result.getProviderResponse()); + log.setProviderMessageId(result.getProviderMessageId()); + chargeTemplateSend(currentUser, connector, renderedContent, phoneNumber); + } else { + retryPolicy.onFailure(log, result); } entityAssociationReferenceResolver.resolve(log); @@ -169,6 +168,16 @@ public SmsLog queueWhatsAppTemplate(Map payload, String username return savedLog; } + /** Bills a WhatsApp template send on a system connector. Unlike processAndSaveSms's text path + * (which debits before an async send even attempts), this runs only once Meta has accepted + * the template, so a tenant is never charged for a rejected send. */ + public void chargeTemplateSend(User user, SmsConnector connector, String renderedContent, String phoneNumber) { + if (!isSystemConnector(connector)) return; + SmsSegmentResult segmentResult = segmentCalculator.calculate(renderedContent); + BigDecimal cost = pricePerSegment.multiply(BigDecimal.valueOf(segmentResult.segments())); + walletService.debit(user, cost, "System connector WHATSAPP template send to " + phoneNumber, null); + } + private String getRequiredObjectField(Map data, String key) { Object value = data.get(key); if (value == null || value.toString().isBlank()) { @@ -256,7 +265,7 @@ private SmsConnector getConnector(User user, String provider, String connectorId enforceTokenConnectorGrant(connector); return connector; } - Optional connector = connectorRepository.findByCreatedByAndProviderAndActiveTrue(user, + Optional connector = connectorRepository.findPreferredActiveConnector(user, provider); if (connector.isPresent()) { enforceTokenConnectorGrant(connector.get()); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java index 61f86cb..29e8f59 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorker.java @@ -1,6 +1,7 @@ package com.flexcodelabs.flextuma.modules.notification.services; import java.util.List; +import java.util.Map; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -10,6 +11,7 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.senders.WhatsAppSender; import com.flexcodelabs.flextuma.core.services.SmsSendResult; import com.flexcodelabs.flextuma.core.services.SmsSender; @@ -21,10 +23,11 @@ @RequiredArgsConstructor public class SmsDispatchWorker { - private static final int MAX_RETRIES = 3; - private final SmsLogRepository logRepository; private final List smsSenders; + private final WhatsAppSender whatsAppSender; + private final NotificationService notificationService; + private final MessageRetryPolicy retryPolicy; @Scheduled(fixedDelay = 5000) @Transactional @@ -54,15 +57,39 @@ private boolean markProcessing(SmsLog smsLog) { private void send(SmsLog smsLog) { SmsConnector connector = smsLog.getConnector(); + smsLog.setDeliveryAttempts(smsLog.getDeliveryAttempts() + 1); + smsLog.setNextRetryAt(null); if (connector == null) { log.error("SmsLog [{}] has no connector — marking FAILED", smsLog.getId()); - smsLog.setStatus(SmsLogStatus.FAILED); - smsLog.setError("No connector associated with this log"); + // No connector can ever be attached to an existing log, so retrying could not help. + retryPolicy.deadLetter(smsLog, "No connector associated with this log"); logRepository.save(smsLog); return; } + SmsSendResult result; + try { + result = smsLog.getWhatsappTemplate() != null ? sendTemplate(smsLog, connector) : sendText(smsLog, connector); + } catch (Exception e) { + result = SmsSendResult.failure(e.getMessage(), "SEND_ERROR", null); + } + + if (result.isSuccess()) { + smsLog.setStatus(SmsLogStatus.SENT); + smsLog.setError(null); + smsLog.setProviderResponse(result.getProviderResponse()); + smsLog.setProviderMessageId(result.getProviderMessageId()); + log.debug("SmsLog [{}] sent successfully via [{}] on attempt {}", smsLog.getId(), connector.getProvider(), + smsLog.getDeliveryAttempts()); + } else { + retryPolicy.onFailure(smsLog, result); + } + + logRepository.save(smsLog); + } + + private SmsSendResult sendText(SmsLog smsLog, SmsConnector connector) { SmsSender sender = smsSenders.stream() .filter(s -> s.getProvider().equalsIgnoreCase(connector.getProvider())) .findFirst() @@ -70,41 +97,28 @@ private void send(SmsLog smsLog) { if (sender == null) { log.error("No SmsSender implementation found for provider [{}]", connector.getProvider()); - smsLog.setStatus(SmsLogStatus.FAILED); - smsLog.setError("No sender implementation for provider: " + connector.getProvider()); - logRepository.save(smsLog); - return; + return SmsSendResult.failure("No sender implementation for provider: " + connector.getProvider(), + "NO_SENDER", null); } + return sender.sendSms(connector, smsLog.getRecipient(), smsLog.getContent()); + } - try { - SmsSendResult result = sender.sendSms(connector, smsLog.getRecipient(), smsLog.getContent()); - - if (result.isSuccess()) { - smsLog.setStatus(SmsLogStatus.SENT); - smsLog.setProviderResponse(result.getProviderResponse()); - smsLog.setProviderMessageId(result.getProviderMessageId()); - log.debug("SmsLog [{}] sent successfully via [{}]", smsLog.getId(), connector.getProvider()); - } else { - smsLog.setStatus(SmsLogStatus.FAILED); - smsLog.setError(result.getMessage()); - smsLog.setProviderResponse(result.getProviderResponse()); - log.warn("SmsLog [{}] FAILED via provider [{}]: {}", smsLog.getId(), connector.getProvider(), - result.getMessage()); - } - } catch (Exception e) { - int retries = smsLog.getRetries() + 1; - smsLog.setRetries(retries); - smsLog.setError(e.getMessage()); - - if (retries >= MAX_RETRIES) { - smsLog.setStatus(SmsLogStatus.FAILED); - log.warn("SmsLog [{}] FAILED after {} retries: {}", smsLog.getId(), retries, e.getMessage()); - } else { - smsLog.setStatus(SmsLogStatus.PENDING); - log.warn("SmsLog [{}] retry {}/{}: {}", smsLog.getId(), retries, MAX_RETRIES, e.getMessage()); + @SuppressWarnings("unchecked") + private SmsSendResult sendTemplate(SmsLog smsLog, SmsConnector connector) { + Map template = smsLog.getWhatsappTemplate(); + Object components = template.get("components"); + SmsSendResult result = whatsAppSender.sendTemplate(connector, smsLog.getRecipient(), + String.valueOf(template.get("name")), String.valueOf(template.get("language")), + components instanceof List list ? (List>) list : List.of()); + if (result.isSuccess()) { + try { + notificationService.chargeTemplateSend(smsLog.getCreatedBy(), connector, smsLog.getContent(), + smsLog.getRecipient()); + } catch (Exception e) { + // The message is already out; failing here would roll back SENT and resend it. + log.error("SmsLog [{}] sent but could not be billed: {}", smsLog.getId(), e.getMessage()); } } - - logRepository.save(smsLog); + return result; } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/retention/controllers/DataRetentionPolicyController.java b/src/main/java/com/flexcodelabs/flextuma/modules/retention/controllers/DataRetentionPolicyController.java new file mode 100644 index 0000000..c4f7fd1 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/retention/controllers/DataRetentionPolicyController.java @@ -0,0 +1,30 @@ +package com.flexcodelabs.flextuma.modules.retention.controllers; + +import com.flexcodelabs.flextuma.modules.retention.dtos.DataRetentionPolicyDTO; +import com.flexcodelabs.flextuma.modules.retention.dtos.DataRetentionPolicyUpdateDTO; +import com.flexcodelabs.flextuma.modules.retention.services.DataRetentionPolicyService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/dataRetentionPolicy") +@RequiredArgsConstructor +public class DataRetentionPolicyController { + + private final DataRetentionPolicyService service; + + @GetMapping + public ResponseEntity get() { + return ResponseEntity.ok(service.getForCurrentUser()); + } + + @PutMapping + public ResponseEntity update(@RequestBody DataRetentionPolicyUpdateDTO update) { + return ResponseEntity.ok(service.updateForCurrentUser(update)); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/retention/dtos/DataRetentionPolicyDTO.java b/src/main/java/com/flexcodelabs/flextuma/modules/retention/dtos/DataRetentionPolicyDTO.java new file mode 100644 index 0000000..a65fb3f --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/retention/dtos/DataRetentionPolicyDTO.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.modules.retention.dtos; + +import lombok.Builder; + +/** + * The caller's account retention settings plus the platform bounds they're chosen within. + * scope is "ORGANISATION" (shared by all members) or "USER". + */ +@Builder +public record DataRetentionPolicyDTO( + String scope, + Integer whatsappEventRetentionDays, + int whatsappEventDefaultDays, + int whatsappEventMaxDays, + int whatsappEventEffectiveDays) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/retention/dtos/DataRetentionPolicyUpdateDTO.java b/src/main/java/com/flexcodelabs/flextuma/modules/retention/dtos/DataRetentionPolicyUpdateDTO.java new file mode 100644 index 0000000..9a3f720 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/retention/dtos/DataRetentionPolicyUpdateDTO.java @@ -0,0 +1,5 @@ +package com.flexcodelabs.flextuma.modules.retention.dtos; + +/** Null or 0 resets to the platform default. */ +public record DataRetentionPolicyUpdateDTO(Integer whatsappEventRetentionDays) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/retention/services/DataRetentionPolicyService.java b/src/main/java/com/flexcodelabs/flextuma/modules/retention/services/DataRetentionPolicyService.java new file mode 100644 index 0000000..b0cb852 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/retention/services/DataRetentionPolicyService.java @@ -0,0 +1,81 @@ +package com.flexcodelabs.flextuma.modules.retention.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.settings.DataRetentionPolicy; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.DataRetentionPolicyRepository; +import com.flexcodelabs.flextuma.modules.retention.dtos.DataRetentionPolicyDTO; +import com.flexcodelabs.flextuma.modules.retention.dtos.DataRetentionPolicyUpdateDTO; +import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppEventRetentionPolicy; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +/** + * Reads and writes the signed-in user's account retention policy (their organisation's when they + * have one). Any signed-in member can change it -- the same trust level as managing the account's + * WhatsApp webhooks. + */ +@Service +@RequiredArgsConstructor +public class DataRetentionPolicyService { + private final DataRetentionPolicyRepository repository; + private final CurrentUserResolver currentUserResolver; + private final WhatsAppEventRetentionPolicy whatsAppEventPolicy; + + @Transactional(readOnly = true) + public DataRetentionPolicyDTO getForCurrentUser() { + User user = currentUser(); + return toDto(user, find(user).map(DataRetentionPolicy::getWhatsappEventRetentionDays).orElse(null)); + } + + @Transactional + public DataRetentionPolicyDTO updateForCurrentUser(DataRetentionPolicyUpdateDTO update) { + User user = currentUser(); + Integer days = update == null ? null : update.whatsappEventRetentionDays(); + whatsAppEventPolicy.validateOverride(days); + Integer stored = days == null || days <= 0 ? null : days; + + DataRetentionPolicy policy = find(user).orElseGet(() -> { + DataRetentionPolicy created = new DataRetentionPolicy(); + if (user.getOrganisation() != null) created.setOrganisation(user.getOrganisation()); + else created.setOwner(user); + return created; + }); + policy.setWhatsappEventRetentionDays(stored); + repository.save(policy); + return toDto(user, stored); + } + + /** The account-level WhatsApp event retention for the tenant that owns a resource, or null. */ + @Transactional(readOnly = true) + public Integer whatsAppEventDaysFor(User owner) { + if (owner == null) return null; + return find(owner).map(DataRetentionPolicy::getWhatsappEventRetentionDays).orElse(null); + } + + private Optional find(User user) { + return user.getOrganisation() != null + ? repository.findByOrganisation(user.getOrganisation()) + : repository.findByOwner(user); + } + + private DataRetentionPolicyDTO toDto(User user, Integer days) { + return DataRetentionPolicyDTO.builder() + .scope(user.getOrganisation() != null ? "ORGANISATION" : "USER") + .whatsappEventRetentionDays(days) + .whatsappEventDefaultDays(whatsAppEventPolicy.defaultDays()) + .whatsappEventMaxDays(whatsAppEventPolicy.maxDays()) + .whatsappEventEffectiveDays(whatsAppEventPolicy.effectiveDays(null, days)) + .build(); + } + + private User currentUser() { + return currentUserResolver.getCurrentUser() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated")); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java index e2c5b8e..ba69439 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogService.java @@ -104,9 +104,18 @@ public SmsLog retryFailedMessage(UUID id) { if (log.getStatus() != SmsLogStatus.FAILED) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Only failed messages can be retried"); } + if (log.getWhatsappTemplate() == null && log.getContent() != null && log.getContent().startsWith("[Template: ")) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "This template message was sent before resend support and cannot be resent; send the template again"); + } + // Like replaying from a Kafka DLT: the message re-enters the queue with a fresh + // backoff budget, while `retries` keeps the lifetime count. log.setStatus(SmsLogStatus.PENDING); log.setRetries(log.getRetries() + 1); + log.setDeliveryAttempts(0); + log.setNextRetryAt(null); + log.setRetryWindowStartedAt(null); log.setError(null); log.setProviderResponse(null); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppMetaAppController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppMetaAppController.java new file mode 100644 index 0000000..701198d --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppMetaAppController.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppMetaApp; +import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppMetaAppService; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/" + WhatsAppMetaApp.PLURAL) +public class WhatsAppMetaAppController extends BaseController { + + public WhatsAppMetaAppController(WhatsAppMetaAppService service) { + super(service); + } +} 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 e63e0b3..58b3038 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 @@ -1,330 +1,182 @@ package com.flexcodelabs.flextuma.modules.whatsapp.controllers; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -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.WhatsAppMetaApp; 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.WhatsAppMetaAppRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; -import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppMediaService; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppSharedWebhookRouter; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppWebhookProcessor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.*; -import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Set; -/** Meta Cloud API webhook endpoint. Events are queued for relay to the owning user's callback URL. */ +/** + * Meta Cloud API webhook endpoints. Events are processed for the owning configuration and queued + * for relay to its callback URL. Three URL shapes are accepted: + *
    + *
  • {@code /{callbackToken}} -- one configuration (one phone number);
  • + *
  • {@code /apps/{callbackToken}} -- a Meta app's shared URL, routed per phone number / WABA;
  • + *
  • the bare path -- legacy, resolved by verify token and phone_number_id.
  • + *
+ */ @Slf4j @RestController @RequestMapping("/api/webhooks/whatsapp") @RequiredArgsConstructor public class WhatsAppWebhookController { - private static final Set MEDIA_MESSAGE_TYPES = Set.of("image", "document", "audio", "video", "sticker"); + private static final String SUBSCRIBE_MODE = "subscribe"; + private static final String MODE_PARAM = "hub.mode"; + private static final String VERIFY_TOKEN_PARAM = "hub.verify_token"; + private static final String CHALLENGE_PARAM = "hub.challenge"; + private static final String SIGNATURE_HEADER = "X-Hub-Signature-256"; + private static final TypeReference> PAYLOAD_TYPE = new TypeReference<>() { + }; private final WhatsAppWebhookConfigRepository configRepository; - private final SmsLogRepository smsLogRepository; - private final WhatsAppInboxMessageRepository inboxMessageRepository; - private final WhatsAppRelayDeliveryRepository relayDeliveryRepository; - private final WhatsAppTemplateRepository templateRepository; - private final WhatsAppMediaService mediaService; + private final WhatsAppMetaAppRepository metaAppRepository; + private final WhatsAppWebhookProcessor processor; + private final WhatsAppSharedWebhookRouter sharedRouter; private final ObjectMapper objectMapper; @GetMapping - public ResponseEntity verify(@RequestParam("hub.mode") String mode, - @RequestParam("hub.verify_token") String verifyToken, - @RequestParam("hub.challenge") String challenge) { + public ResponseEntity verify(@RequestParam(MODE_PARAM) String mode, + @RequestParam(VERIFY_TOKEN_PARAM) String verifyToken, @RequestParam(CHALLENGE_PARAM) String challenge) { Optional config = configRepository.findByVerifyTokenAndActiveTrue(verifyToken); - if ("subscribe".equals(mode) && config.isPresent()) { markVerified(config.get()); return ResponseEntity.ok(challenge); } - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + if (!SUBSCRIBE_MODE.equals(mode) || config.isEmpty()) { + return forbidden(); + } + markVerified(config.get()); + return ResponseEntity.ok(challenge); } @GetMapping("/{callbackToken}") public ResponseEntity verifyGeneratedCallback(@PathVariable String callbackToken, - @RequestParam("hub.mode") String mode, @RequestParam("hub.verify_token") String verifyToken, - @RequestParam("hub.challenge") String challenge) { - Optional config = configRepository.findByCallbackTokenAndActiveTrue(callbackToken); - if (config.isPresent() && "subscribe".equals(mode) && MessageDigest.isEqual( - config.get().getVerifyToken().getBytes(StandardCharsets.UTF_8), verifyToken.getBytes(StandardCharsets.UTF_8))) { markVerified(config.get()); return ResponseEntity.ok(challenge); } - return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); - } - - private void markVerified(WhatsAppWebhookConfig config) { - config.setLastVerifiedAt(LocalDateTime.now()); - configRepository.save(config); - } - - private void markEventReceived(WhatsAppWebhookConfig config) { - config.setLastEventAt(LocalDateTime.now()); - configRepository.save(config); + @RequestParam(MODE_PARAM) String mode, @RequestParam(VERIFY_TOKEN_PARAM) String verifyToken, + @RequestParam(CHALLENGE_PARAM) String challenge) { + Optional config = configRepository.findByCallbackTokenAndActiveTrue(callbackToken) + .filter(found -> isSubscription(mode, found.getVerifyToken(), verifyToken)); + if (config.isEmpty()) { + return forbidden(); + } + markVerified(config.get()); + return ResponseEntity.ok(challenge); + } + + @GetMapping("/apps/{callbackToken}") + public ResponseEntity verifySharedCallback(@PathVariable String callbackToken, + @RequestParam(MODE_PARAM) String mode, @RequestParam(VERIFY_TOKEN_PARAM) String verifyToken, + @RequestParam(CHALLENGE_PARAM) String challenge) { + Optional app = metaAppRepository.findByCallbackTokenAndActiveTrue(callbackToken) + .filter(found -> isSubscription(mode, found.getVerifyToken(), verifyToken)); + if (app.isEmpty()) { + return forbidden(); + } + app.get().setLastVerifiedAt(LocalDateTime.now()); + metaAppRepository.save(app.get()); + return ResponseEntity.ok(challenge); } @PostMapping public ResponseEntity receive(@RequestBody String rawPayload, - @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature) { - Map payload; - try { - payload = objectMapper.readValue(rawPayload, Map.class); - } catch (Exception e) { - log.warn("Rejecting WhatsApp webhook with unparsable JSON: {}", e.getMessage()); + @RequestHeader(value = SIGNATURE_HEADER, required = false) String signature) { + Optional> payload = parse(rawPayload); + if (payload.isEmpty()) { return ResponseEntity.badRequest().build(); } - try { - return handle(payload, rawPayload, signature, phoneNumberId(payload).flatMap(configRepository::findByPhoneNumberIdAndActiveTrue)); - } catch (RuntimeException e) { - log.error("WhatsApp webhook processing failed", e); - throw e; - } + Optional config = WhatsAppPayloads.phoneNumberIdOfPayload(payload.get()) + .flatMap(configRepository::findByPhoneNumberIdAndActiveTrue); + return handle(payload.get(), rawPayload, signature, config); } + /** The callback token already scopes this request to exactly one config (it's unique and + * unguessable), and the Meta signature is the real authenticity check. Meta delivers many event + * types (template status, account alerts, etc.) with no metadata.phone_number_id at all, so + * none is required here. */ @PostMapping("/{callbackToken}") public ResponseEntity receiveGeneratedCallback(@PathVariable String callbackToken, @RequestBody String rawPayload, - @RequestHeader(value = "X-Hub-Signature-256", required = false) String signature) { - try { - Map payload = objectMapper.readValue(rawPayload, Map.class); - // The callback token itself already scopes this request to exactly one config (it's - // unique and unguessable), and validMetaSignature() is the real authenticity check. - // Meta delivers many event types (template status, account alerts, etc.) that carry no - // metadata.phone_number_id at all, so requiring one here rejected legitimate events. - Optional config = configRepository.findByCallbackTokenAndActiveTrue(callbackToken); - return handle(payload, rawPayload, signature, config); - } catch (Exception e) { - log.error("WhatsApp webhook processing failed for callback token [{}]", callbackToken, e); + @RequestHeader(value = SIGNATURE_HEADER, required = false) String signature) { + Optional> payload = parse(rawPayload); + if (payload.isEmpty()) { return ResponseEntity.badRequest().build(); } + return handle(payload.get(), rawPayload, signature, configRepository.findByCallbackTokenAndActiveTrue(callbackToken)); } - private ResponseEntity handle(Map payload, String rawPayload, String signature, Optional config) { - 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); updateTemplateStatus(config.get(), payload); relay(config.get(), payload); - log.info("Processed WhatsApp webhook for config [{}]: {} change(s)", config.get().getId(), changes(payload).size()); - return ResponseEntity.ok().build(); - } - - private Optional phoneNumberId(Map payload) { - return changes(payload).stream().map(change -> nestedMap(change, "value")) - .map(value -> nestedMap(value, "metadata")).map(metadata -> metadata.get("phone_number_id")) - .filter(value -> value != null && !value.toString().isBlank()).map(Object::toString).findFirst(); - } - - private void updateDeliveryStatus(Map payload) { - for (Map change : changes(payload)) { - Object statuses = nestedMap(change, "value").get("statuses"); - if (!(statuses instanceof List list)) continue; - for (Object status : list) { - if (!(status instanceof Map raw)) continue; - Object id = raw.get("id"), value = raw.get("status"); - if (id != null && value != null) smsLogRepository.findByProviderMessageId(id.toString()).ifPresent(log -> applyStatus(log, value.toString(), (Map) raw)); - } - } - } - - /** 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. Both lookups are scoped to config's owner -- metaTemplateId and - * name+language are each only unique per (value, creator), so an unscoped lookup could match - * a different tenant's template of the same id or the same common name (e.g. - * "otp_verification"), corrupting their approval status instead. */ - @SuppressWarnings("unchecked") - private void updateTemplateStatus(WhatsAppWebhookConfig config, 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.findFirstByMetaTemplateIdAndCreatedBy(templateId.toString(), config.getCreatedBy()) - : 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.findFirstByNameAndLanguageAndCreatedBy(name.toString(), - language.toString(), config.getCreatedBy()); - } - } - 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 - // a step past it. - private static final List DELIVERY_PROGRESSION = List.of(SmsLogStatus.SENT, SmsLogStatus.DELIVERED, SmsLogStatus.READ); - - @SuppressWarnings("unchecked") - private void applyStatus(SmsLog logEntry, String status, Map raw) { - SmsLogStatus next; - if ("read".equalsIgnoreCase(status)) next = SmsLogStatus.READ; - else if ("delivered".equalsIgnoreCase(status)) next = SmsLogStatus.DELIVERED; - else if ("failed".equalsIgnoreCase(status)) next = SmsLogStatus.FAILED; - else if ("sent".equalsIgnoreCase(status)) next = SmsLogStatus.SENT; - else return; - - if (next != SmsLogStatus.FAILED && isDeliveryRegression(logEntry.getStatus(), next)) return; - - logEntry.setStatus(next); - if (next == SmsLogStatus.FAILED) logEntry.setError(extractErrorMessage(raw)); - smsLogRepository.save(logEntry); - } - - private boolean isDeliveryRegression(SmsLogStatus current, SmsLogStatus next) { - // List.of(...)'s indexOf() throws NPE on a null argument (unlike ArrayList's, which just - // returns -1) -- and current is null for a log whose status hasn't been set yet, so this - // must short-circuit before reaching DELIVERY_PROGRESSION.indexOf(current) below. - if (current == null) { - return false; - } - int currentIndex = DELIVERY_PROGRESSION.indexOf(current); - int nextIndex = DELIVERY_PROGRESSION.indexOf(next); - return currentIndex >= 0 && nextIndex >= 0 && nextIndex < currentIndex; - } - - private String extractErrorMessage(Map raw) { - Object errors = raw.get("errors"); - if (!(errors instanceof List list) || list.isEmpty() || !(list.get(0) instanceof Map first)) return null; - Object message = first.get("message"), title = first.get("title"), code = first.get("code"); - String text = message != null ? message.toString() : title != null ? title.toString() : "WhatsApp delivery failed"; - return code != null ? "[" + code + "] " + text : text; - } - - @SuppressWarnings("unchecked") - private void ingestInboundMessages(WhatsAppWebhookConfig config, Map payload) { - for (Map change : changes(payload)) { - Map value = nestedMap(change, "value"); - Object messages = value.get("messages"); - if (!(messages instanceof List list)) continue; - Map contactNames = contactNamesByWaId(value); - for (Object item : list) { - if (!(item instanceof Map raw)) continue; - saveInboundMessage(config, (Map) raw, contactNames); - } - } - } - - @SuppressWarnings("unchecked") - private Map contactNamesByWaId(Map value) { - Object contacts = value.get("contacts"); - if (!(contacts instanceof List list)) return Map.of(); - Map names = new HashMap<>(); - for (Object item : list) { - if (!(item instanceof Map raw)) continue; - Object waId = raw.get("wa_id"); - Object name = nestedMap((Map) raw, "profile").get("name"); - if (waId != null && name != null) names.put(waId.toString(), name.toString()); + @PostMapping("/apps/{callbackToken}") + public ResponseEntity receiveSharedCallback(@PathVariable String callbackToken, @RequestBody String rawPayload, + @RequestHeader(value = SIGNATURE_HEADER, required = false) String signature) { + Optional> payload = parse(rawPayload); + if (payload.isEmpty()) { + return ResponseEntity.badRequest().build(); } - return names; - } - - private void saveInboundMessage(WhatsAppWebhookConfig config, Map raw, Map contactNames) { - Object idObj = raw.get("id"), fromObj = raw.get("from"), typeObj = raw.get("type"); - if (idObj == null || fromObj == null) return; - String providerMessageId = idObj.toString(); - if (inboxMessageRepository.existsByProviderMessageId(providerMessageId)) return; - String type = typeObj != null ? typeObj.toString() : "unknown"; - WhatsAppInboxMessage message = new WhatsAppInboxMessage(); - message.setConfig(config); - message.setCreatedBy(config.getCreatedBy()); - message.setFromNumber(fromObj.toString()); - message.setContactName(contactNames.get(fromObj.toString())); - message.setProviderMessageId(providerMessageId); - message.setMessageType(type); - message.setReceivedAt(parseTimestamp(raw.get("timestamp"))); - - if (MEDIA_MESSAGE_TYPES.contains(type)) { - attachMedia(message, config, nestedMap(raw, type)); - } else { - message.setContent(extractInboundContent(raw, type)); + Optional app = metaAppRepository.findByCallbackTokenAndActiveTrue(callbackToken); + if (app.isEmpty()) { + log.warn("Ignoring shared WhatsApp webhook with no active Meta app"); + return ResponseEntity.ok().build(); } - - try { - inboxMessageRepository.save(message); - } catch (DataIntegrityViolationException e) { - log.debug("Duplicate WhatsApp inbound message [{}] ignored", providerMessageId); + if (!processor.validSignature(app.get(), rawPayload, signature)) { + log.warn("Rejecting shared WhatsApp webhook with an invalid Meta signature for app [{}]", app.get().getId()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } + app.get().setLastEventAt(LocalDateTime.now()); + metaAppRepository.save(app.get()); + sharedRouter.route(app.get(), payload.get()).forEach(routed -> processor.process(routed.config(), routed.payload())); + return ResponseEntity.ok().build(); } - private void attachMedia(WhatsAppInboxMessage message, WhatsAppWebhookConfig config, Map media) { - Object mediaId = media.get("id"), mimeType = media.get("mime_type"), caption = media.get("caption"); - message.setContent(caption != null ? caption.toString() : null); - if (mediaId == null) { - return; + private ResponseEntity handle(Map payload, String rawPayload, String signature, + Optional config) { + if (config.isEmpty()) { + log.warn("Ignoring WhatsApp webhook with no active configuration"); + return ResponseEntity.ok().build(); } - message.setMediaId(mediaId.toString()); - message.setMimeType(mimeType != null ? mimeType.toString() : null); - message.setCaption(caption != null ? caption.toString() : null); - mediaService.download(config, mediaId.toString()).ifPresent(downloaded -> { - message.setMediaPath(downloaded.path()); - message.setMediaSize(downloaded.size()); - }); - } - - private String extractInboundContent(Map raw, String type) { - if ("text".equals(type)) { - Object body = nestedMap(raw, "text").get("body"); - if (body != null) return body.toString(); + if (!processor.validSignature(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(); } - return "[" + type + "]"; + processor.process(config.get(), payload); + return ResponseEntity.ok().build(); } - private LocalDateTime parseTimestamp(Object timestamp) { + private Optional> parse(String rawPayload) { try { - return LocalDateTime.ofEpochSecond(Long.parseLong(timestamp.toString()), 0, ZoneOffset.UTC); - } catch (Exception e) { - return LocalDateTime.now(); + return Optional.ofNullable(objectMapper.readValue(rawPayload, PAYLOAD_TYPE)); + } catch (JsonProcessingException e) { + log.warn("Rejecting WhatsApp webhook with unparsable JSON: {}", e.getMessage()); + return Optional.empty(); } } - private void relay(WhatsAppWebhookConfig config, Map payload) { - if (config.getCallbackUrl() == null || config.getCallbackUrl().isBlank()) return; - try { - WhatsAppRelayDelivery delivery = new WhatsAppRelayDelivery(); - delivery.setConfig(config); - delivery.setCreatedBy(config.getCreatedBy()); - delivery.setPayload(objectMapper.writeValueAsString(payload)); - relayDeliveryRepository.save(delivery); - } catch (Exception e) { log.warn("Unable to queue WhatsApp relay for config [{}]: {}", config.getId(), e.getMessage()); } + private static boolean isSubscription(String mode, String expectedToken, String verifyToken) { + return SUBSCRIBE_MODE.equals(mode) && expectedToken != null && MessageDigest.isEqual( + expectedToken.getBytes(StandardCharsets.UTF_8), verifyToken.getBytes(StandardCharsets.UTF_8)); } - private boolean validMetaSignature(WhatsAppWebhookConfig config, String rawPayload, String signature) { - if (config.getAppSecret() == null || config.getAppSecret().isBlank()) return true; - if (signature == null || !signature.startsWith("sha256=")) return false; - try { - String expected = "sha256=" + HmacUtil.sha256Hex(rawPayload, config.getAppSecret()); - return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), signature.getBytes(StandardCharsets.US_ASCII)); - } catch (Exception e) { return false; } + private void markVerified(WhatsAppWebhookConfig config) { + config.setLastVerifiedAt(LocalDateTime.now()); + configRepository.save(config); } - @SuppressWarnings("unchecked") private Map nestedMap(Map source, String key) { Object value = source.get(key); return value instanceof Map map ? (Map) map : Map.of(); } - @SuppressWarnings("unchecked") private List> changes(Map payload) { - Object entries = payload.get("entry"); if (!(entries instanceof List entryList)) return List.of(); - return entryList.stream().filter(Map.class::isInstance).flatMap(entry -> { Object values = ((Map) entry).get("changes"); return values instanceof List list ? list.stream() : java.util.stream.Stream.empty(); }).filter(Map.class::isInstance).map(item -> (Map) item).toList(); + private static ResponseEntity forbidden() { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookEventController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookEventController.java new file mode 100644 index 0000000..2d373ec --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookEventController.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookEvent; +import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppWebhookEventService; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/" + WhatsAppWebhookEvent.PLURAL) +public class WhatsAppWebhookEventController extends BaseController { + + public WhatsAppWebhookEventController(WhatsAppWebhookEventService service) { + super(service); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionPolicy.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionPolicy.java new file mode 100644 index 0000000..df6d9a4 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionPolicy.java @@ -0,0 +1,46 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Data retention policy for the WhatsApp webhook event log. The platform sets a default and a + * maximum; an account (DataRetentionPolicy) may override the default, and a single webhook config + * may override its account via eventRetentionDays. Precedence: webhook, then account, then platform + * default, always capped at the maximum. Null or 0 at any level means "inherit". + */ +@Component +public class WhatsAppEventRetentionPolicy { + private final int defaultDays; + private final int maxDays; + + public WhatsAppEventRetentionPolicy( + @Value("${flextuma.whatsapp.events.retention-days:30}") int defaultDays, + @Value("${flextuma.whatsapp.events.max-retention-days:365}") int maxDays) { + this.maxDays = Math.max(1, maxDays); + this.defaultDays = Math.min(Math.max(1, defaultDays), this.maxDays); + } + + public int defaultDays() { + return defaultDays; + } + + public int maxDays() { + return maxDays; + } + + /** Days actually applied given a webhook override and an account override (either may be null). */ + public int effectiveDays(Integer webhookDays, Integer accountDays) { + if (webhookDays != null && webhookDays > 0) return Math.min(webhookDays, maxDays); + if (accountDays != null && accountDays > 0) return Math.min(accountDays, maxDays); + return defaultDays; + } + + /** Rejects an override outside 0..maxDays (0 resets it to inherit). */ + public void validateOverride(Integer override) { + if (override != null && (override < 0 || override > maxDays)) { + throw new IllegalArgumentException("eventRetentionDays must be between 1 and " + maxDays + + " days (or 0 to inherit the default)"); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionScheduler.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionScheduler.java new file mode 100644 index 0000000..c86f890 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionScheduler.java @@ -0,0 +1,49 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookEventRepository; +import com.flexcodelabs.flextuma.modules.retention.services.DataRetentionPolicyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** Nightly purge of the WhatsApp event log according to WhatsAppEventRetentionPolicy. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WhatsAppEventRetentionScheduler { + private final WhatsAppWebhookEventRepository eventRepository; + private final WhatsAppWebhookConfigRepository configRepository; + private final WhatsAppEventRetentionPolicy policy; + private final DataRetentionPolicyService accountPolicies; + + /** One delete per webhook config -- there's one config per WhatsApp phone number, so this stays small. */ + @Scheduled(cron = "${flextuma.whatsapp.events.purge-cron:0 30 2 * * *}") + @Transactional + public void purgeExpiredEvents() { + LocalDateTime now = LocalDateTime.now(); + Map> accountDaysByOwner = new HashMap<>(); + int deleted = 0; + for (WhatsAppWebhookConfig config : configRepository.findAll()) { + User owner = config.getCreatedBy(); + Integer accountDays = owner == null ? null : accountDaysByOwner + .computeIfAbsent(owner.getId(), id -> Optional.ofNullable(accountPolicies.whatsAppEventDaysFor(owner))) + .orElse(null); + int days = policy.effectiveDays(config.getEventRetentionDays(), accountDays); + deleted += eventRepository.deleteByConfigIdAndReceivedAtBefore(config.getId(), now.minusDays(days)); + } + if (deleted > 0) { + log.info("Purged {} WhatsApp webhook events past their retention period", deleted); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaService.java index ecc3449..802b21d 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaService.java @@ -105,15 +105,15 @@ private String tenantFolder(User owner) { } /** Prefers the webhook config's explicitly linked connector; falls back to the owner's - * first active WhatsApp connector for configs that predate that link (ambiguous if the - * owner has more than one). Package-private: also reused by WhatsAppInboxMessageService to + * preferred active WhatsApp connector (isDefault first, then oldest) for configs without one. + * Package-private: also reused by WhatsAppInboxMessageService to * resolve the connector needed to send Meta read receipts. */ SmsConnector resolveConnector(WhatsAppWebhookConfig config) { if (config.getConnector() != null) { return config.getConnector(); } return smsConnectorRepository - .findByCreatedByAndProviderAndActiveTrue(config.getCreatedBy(), WHATSAPP_PROVIDER) + .findPreferredActiveConnector(config.getCreatedBy(), WHATSAPP_PROVIDER) .orElse(null); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMetaAppService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMetaAppService.java new file mode 100644 index 0000000..3a8bfe7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMetaAppService.java @@ -0,0 +1,84 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppMetaApp; +import com.flexcodelabs.flextuma.core.helpers.TokenGenerator; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppMetaAppRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +/** Shared Meta app callback URLs; tenant-scoped by BaseService like webhook configurations. */ +@Service +@RequiredArgsConstructor +public class WhatsAppMetaAppService extends BaseService { + private static final String ALL = "ALL"; + private static final String MASK = "****"; + private static final int VERIFY_TOKEN_LENGTH = 32; + + private final WhatsAppMetaAppRepository repository; + private final WhatsAppWebhookConfigRepository configRepository; + @Value("${flextuma.public-base-url:}") private String publicBaseUrl; + + @Override protected JpaRepository getRepository() { return repository; } + @Override protected JpaSpecificationExecutor getRepositoryAsExecutor() { return repository; } + @Override protected String getReadPermission() { return ALL; } + @Override protected String getAddPermission() { return ALL; } + @Override protected String getUpdatePermission() { return ALL; } + @Override protected String getDeletePermission() { return ALL; } + @Override public String getEntityPlural() { return WhatsAppMetaApp.NAME_PLURAL; } + @Override protected String getEntitySingular() { return WhatsAppMetaApp.NAME_SINGULAR; } + @Override public String getPropertyName() { return WhatsAppMetaApp.PLURAL; } + @Override protected String getTableName() { return "whatsapp_meta_app"; } + + @Override + protected void onPreSave(WhatsAppMetaApp entity) { + if (publicBaseUrl == null || publicBaseUrl.isBlank()) { + throw new IllegalStateException("FLEXTUMA_PUBLIC_BASE_URL must be configured before WhatsApp Meta apps can be created"); + } + if (entity.getVerifyToken() == null || entity.getVerifyToken().isBlank()) { + entity.setVerifyToken(TokenGenerator.generateSecureToken(VERIFY_TOKEN_LENGTH)); + } + entity.setCallbackToken(UUID.randomUUID().toString().replace("-", "")); + entity.setMetaCallbackUrl(publicBaseUrl.replaceAll("/+$", "") + "/api/webhooks/whatsapp/apps/" + entity.getCallbackToken()); + } + + /** The callback URL and verification timestamps are owned by Flextuma; the verify token and app + * secret can be rotated, and submitting back the masked placeholder keeps the stored value. */ + @Override + protected WhatsAppMetaApp onPreUpdate(WhatsAppMetaApp entity, WhatsAppMetaApp old) { + entity.setCallbackToken(old.getCallbackToken()); + entity.setMetaCallbackUrl(old.getMetaCallbackUrl()); + entity.setLastVerifiedAt(old.getLastVerifiedAt()); + entity.setLastEventAt(old.getLastEventAt()); + if (isMasked(entity.getVerifyToken())) { + entity.setVerifyToken(old.getVerifyToken()); + } + if (isMasked(entity.getAppSecret())) { + entity.setAppSecret(old.getAppSecret()); + } + return super.onPreUpdate(entity, old); + } + + /** Linked webhook configurations are detached rather than deleted: each keeps working through + * its own per-number callback URL (and its own app secret, if set). */ + @Override + @Transactional + public Map delete(UUID id) { + checkPermission(getDeletePermission()); + WhatsAppMetaApp app = findAccessibleById(id); + configRepository.detachMetaApp(app.getId()); + return super.delete(id); + } + + private static boolean isMasked(String value) { + return value != null && value.contains(MASK); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppRelayDispatchWorker.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppRelayDispatchWorker.java index 678640a..3ead991 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppRelayDispatchWorker.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppRelayDispatchWorker.java @@ -1,13 +1,15 @@ package com.flexcodelabs.flextuma.modules.whatsapp.services; -import com.fasterxml.jackson.databind.ObjectMapper; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppRelayDelivery; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.enums.WhatsAppRelayStatus; import com.flexcodelabs.flextuma.core.helpers.HmacUtil; import com.flexcodelabs.flextuma.core.repositories.WhatsAppRelayDeliveryRepository; -import lombok.RequiredArgsConstructor; +import com.flexcodelabs.flextuma.modules.notification.services.MessageRetryPolicy; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -16,32 +18,61 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; - +import java.util.Optional; + +/** + * Relays inbound WhatsApp webhooks to the customer's callback URL. Failed deliveries follow the + * same Kafka retry-topic style policy as outbound messages ({@link MessageRetryPolicy}): every + * failure, 4xx included (the customer may fix their endpoint), is re-queued with an exponentially + * growing delay until the retry window measured from the delivery's creation runs out. + * Relay-specific {@code flextuma.whatsapp.relay.retry.*} properties override the shared + * {@code flextuma.messaging.retry.*} ones. + */ @Slf4j @Service -@RequiredArgsConstructor public class WhatsAppRelayDispatchWorker { - private static final int MAX_RETRIES = 3; + private static final int BATCH_SIZE = 50; private final WhatsAppRelayDeliveryRepository deliveryRepository; private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; + private final MessageRetryPolicy retryPolicy; + + @Autowired + public WhatsAppRelayDispatchWorker( + WhatsAppRelayDeliveryRepository deliveryRepository, + RestTemplate restTemplate, + @Value("${flextuma.whatsapp.relay.retry.initial-interval-ms:${flextuma.messaging.retry.initial-interval-ms:10000}}") long initialIntervalMs, + @Value("${flextuma.whatsapp.relay.retry.multiplier:${flextuma.messaging.retry.multiplier:2.0}}") double multiplier, + @Value("${flextuma.whatsapp.relay.retry.max-interval-ms:${flextuma.messaging.retry.max-interval-ms:3600000}}") long maxIntervalMs, + @Value("${flextuma.whatsapp.relay.retry.window-ms:${flextuma.messaging.retry.window-ms:43200000}}") long retryWindowMs) { + this(deliveryRepository, restTemplate, + new MessageRetryPolicy(initialIntervalMs, multiplier, maxIntervalMs, retryWindowMs)); + } + + public WhatsAppRelayDispatchWorker(WhatsAppRelayDeliveryRepository deliveryRepository, RestTemplate restTemplate, + MessageRetryPolicy retryPolicy) { + this.deliveryRepository = deliveryRepository; + this.restTemplate = restTemplate; + this.retryPolicy = retryPolicy; + } @Scheduled(fixedDelay = 5000) @Transactional public void dispatch() { - List pending = deliveryRepository.findTop50ByStatusOrderByCreatedAsc(WhatsAppRelayStatus.PENDING); + List due = deliveryRepository.findDueDeliveries(WhatsAppRelayStatus.PENDING, + LocalDateTime.now(), PageRequest.of(0, BATCH_SIZE)); - if (pending.isEmpty()) { + if (due.isEmpty()) { return; } - log.debug("WhatsAppRelayDispatchWorker: picking up {} PENDING deliverie(s)", pending.size()); + log.debug("WhatsAppRelayDispatchWorker: picking up {} due deliverie(s)", due.size()); - for (WhatsAppRelayDelivery delivery : pending) { + for (WhatsAppRelayDelivery delivery : due) { if (markProcessing(delivery)) { send(delivery); } @@ -58,35 +89,62 @@ private void send(WhatsAppRelayDelivery delivery) { long startedAt = System.currentTimeMillis(); try { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("X-Flextuma-Event", "whatsapp"); - if (config.getSigningSecret() != null && !config.getSigningSecret().isBlank()) { - headers.set("X-Flextuma-Signature-256", "sha256=" + HmacUtil.sha256Hex(delivery.getPayload(), config.getSigningSecret())); - } - - restTemplate.postForEntity(config.getCallbackUrl(), new HttpEntity<>(delivery.getPayload(), headers), Void.class); - + restTemplate.postForEntity(config.getCallbackUrl(), + new HttpEntity<>(delivery.getPayload(), buildHeaders(delivery, config)), Void.class); + recordAttempt(delivery, startedAt); delivery.setStatus(WhatsAppRelayStatus.SUCCEEDED); - delivery.setLatencyMs(System.currentTimeMillis() - startedAt); - delivery.setLastAttemptAt(LocalDateTime.now()); + delivery.setNextRetryAt(null); log.debug("WhatsApp relay [{}] delivered successfully for config [{}]", delivery.getId(), config.getId()); } catch (Exception e) { - int retries = delivery.getRetries() + 1; - delivery.setRetries(retries); - delivery.setLatencyMs(System.currentTimeMillis() - startedAt); - delivery.setLastAttemptAt(LocalDateTime.now()); - delivery.setLastError(e.getMessage()); - - if (retries >= MAX_RETRIES) { - delivery.setStatus(WhatsAppRelayStatus.FAILED); - log.warn("WhatsApp relay [{}] FAILED after {} retries: {}", delivery.getId(), retries, e.getMessage()); - } else { - delivery.setStatus(WhatsAppRelayStatus.PENDING); - log.warn("WhatsApp relay [{}] retry {}/{}: {}", delivery.getId(), retries, MAX_RETRIES, e.getMessage()); - } + recordAttempt(delivery, startedAt); + onFailure(delivery, e); } deliveryRepository.save(delivery); } + + private HttpHeaders buildHeaders(WhatsAppRelayDelivery delivery, WhatsAppWebhookConfig config) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Flextuma-Event", "whatsapp"); + String secret = config.getSigningSecret(); + if (secret != null && !secret.isBlank()) { + headers.set("X-Flextuma-Signature-256", "sha256=" + sign(delivery.getPayload(), secret)); + } + return headers; + } + + private static String sign(String payload, String secret) { + try { + return HmacUtil.sha256Hex(payload, secret); + } catch (Exception e) { + throw new IllegalStateException("Could not sign relay payload: " + e.getMessage(), e); + } + } + + private void recordAttempt(WhatsAppRelayDelivery delivery, long startedAt) { + delivery.setLatencyMs(System.currentTimeMillis() - startedAt); + delivery.setLastAttemptAt(LocalDateTime.now()); + } + + /** Schedules the next retry with backoff, or dead-letters the delivery as FAILED once the window closes. */ + private void onFailure(WhatsAppRelayDelivery delivery, Exception e) { + int attempt = delivery.getRetries() + 1; + delivery.setRetries(attempt); + delivery.setLastError(e.getMessage()); + + Optional delay = retryPolicy.nextRetryDelay(attempt, delivery.getCreated()); + if (delay.isPresent()) { + delivery.setStatus(WhatsAppRelayStatus.PENDING); + delivery.setNextRetryAt(LocalDateTime.now().plus(delay.get())); + log.warn("WhatsApp relay [{}] attempt {} failed, retrying in {}s: {}", delivery.getId(), attempt, + delay.get().toSeconds(), e.getMessage()); + return; + } + + delivery.setStatus(WhatsAppRelayStatus.FAILED); + delivery.setNextRetryAt(null); + log.warn("WhatsApp relay [{}] FAILED after {} attempt(s), retry window exhausted: {}", delivery.getId(), + attempt, e.getMessage()); + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigService.java index a08ecae..7c817ef 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigService.java @@ -4,10 +4,12 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppRelayDelivery; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppMetaApp; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.enums.WhatsAppRelayStatus; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppMetaAppRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; import com.flexcodelabs.flextuma.core.services.BaseService; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppActivityItemDTO; @@ -43,7 +45,9 @@ public class WhatsAppWebhookConfigService extends BaseService getRepository() { return repository; } protected JpaSpecificationExecutor getRepositoryAsExecutor() { return repository; } @@ -86,6 +90,8 @@ private void provisionMetaCallback(WhatsAppWebhookConfig entity) { private void validate(WhatsAppWebhookConfig entity) { validateCallbackUrl(entity); validateConnector(entity); + validateMetaApp(entity); + eventRetentionPolicy.validateOverride(entity.getEventRetentionDays()); } private void validateCallbackUrl(WhatsAppWebhookConfig entity) { @@ -117,6 +123,26 @@ private void validateConnector(WhatsAppWebhookConfig entity) { entity.setConnector(connector); } + // A shared Meta app's secret validates every event routed to this config, so -- like the + // connector -- it must belong to the current user. + private void validateMetaApp(WhatsAppWebhookConfig entity) { + if (entity.getMetaApp() == null || entity.getMetaApp().getId() == null) { + return; + } + WhatsAppMetaApp app = metaAppRepository.findByIdAndActiveTrue(entity.getMetaApp().getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Meta app not found or inactive")); + if (!isOwnedByCurrentUser(app.getCreatedBy())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "You can only link your own Meta app"); + } + entity.setMetaApp(app); + } + + private boolean isOwnedByCurrentUser(User owner) { + User currentUser = currentUserResolver.getCurrentUser() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated")); + return owner != null && owner.getId().equals(currentUser.getId()); + } + private static final int OVERVIEW_SCAN_LIMIT = 5000; // findAllPaginated below is called on `this`, which bypasses the Spring proxy that backs diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookEventService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookEventService.java new file mode 100644 index 0000000..2b49a53 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookEventService.java @@ -0,0 +1,114 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookEvent; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookEventRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; +import com.flexcodelabs.flextuma.core.dtos.Pagination; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +/** + * Read-only browsing of the WhatsApp webhook event log. Same permission shape as + * WhatsAppTemplateService: READ is the "ALL" sentinel (any tenant user, scoped to their own rows by + * BaseService's tenant filtering); writes need a permission no tenant role holds, since events are + * only ever recorded by WhatsAppWebhookController via the repository. + */ +@Service +@RequiredArgsConstructor +public class WhatsAppWebhookEventService extends BaseService { + private final WhatsAppWebhookEventRepository repository; + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + /** Closes BaseService#checkPermission's generic "ALL" bypass so tenants can't fabricate events. */ + @Override + protected boolean isAdminEntity() { + return true; + } + + /** isAdminEntity() also disables the "ALL" sentinel for READ; let it through explicitly. */ + @Override + protected void checkPermission(String requiredPermission) { + if (WhatsAppWebhookEvent.READ.equals(requiredPermission)) { + return; + } + super.checkPermission(requiredPermission); + } + + /** A log reads newest-first; the shared list endpoint otherwise returns rows unordered. */ + @Override + @Transactional(readOnly = true) + public Pagination findAllPaginated(Pageable pageable, List filter, String fields) { + return super.findAllPaginated(newestFirst(pageable), filter, fields); + } + + @Override + @Transactional(readOnly = true) + public Pagination findAllPaginated(Pageable pageable, List filter, String fields, + String rootJoin) { + return super.findAllPaginated(newestFirst(pageable), filter, fields, rootJoin); + } + + private Pageable newestFirst(Pageable pageable) { + return pageable.isPaged() && pageable.getSort().isUnsorted() + ? PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), Sort.by(Sort.Direction.DESC, "receivedAt")) + : pageable; + } + + @Override + protected String getReadPermission() { + return WhatsAppWebhookEvent.READ; + } + + @Override + protected String getAddPermission() { + return WhatsAppWebhookEvent.ADD; + } + + @Override + protected String getUpdatePermission() { + return WhatsAppWebhookEvent.UPDATE; + } + + @Override + protected String getDeletePermission() { + return WhatsAppWebhookEvent.DELETE; + } + + @Override + public String getEntityPlural() { + return WhatsAppWebhookEvent.NAME_PLURAL; + } + + @Override + protected String getEntitySingular() { + return WhatsAppWebhookEvent.NAME_SINGULAR; + } + + @Override + public String getPropertyName() { + return WhatsAppWebhookEvent.PLURAL; + } + + @Override + protected String getTableName() { + return "whatsapp_webhook_event"; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppInboundMessageIngestor.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppInboundMessageIngestor.java new file mode 100644 index 0000000..952bfb9 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppInboundMessageIngestor.java @@ -0,0 +1,109 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.webhook; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppInboxMessageRepository; +import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppMediaService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads.MESSAGES; + +/** Stores inbound customer messages (and their media) in the owner's WhatsApp Inbox. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WhatsAppInboundMessageIngestor { + private static final Set MEDIA_MESSAGE_TYPES = Set.of("image", "document", "audio", "video", "sticker"); + + private final WhatsAppInboxMessageRepository inboxMessageRepository; + private final WhatsAppMediaService mediaService; + + public void ingest(WhatsAppWebhookConfig config, Map payload) { + for (Map change : WhatsAppPayloads.changes(payload)) { + Map value = WhatsAppPayloads.value(change); + Map contactNames = contactNamesByWaId(value); + WhatsAppPayloads.mapList(value, MESSAGES).forEach(raw -> saveInboundMessage(config, raw, contactNames)); + } + } + + private Map contactNamesByWaId(Map value) { + Map names = new HashMap<>(); + for (Map contact : WhatsAppPayloads.mapList(value, "contacts")) { + Object waId = contact.get("wa_id"); + Object name = WhatsAppPayloads.nestedMap(contact, "profile").get("name"); + if (waId != null && name != null) { + names.put(waId.toString(), name.toString()); + } + } + return names; + } + + private void saveInboundMessage(WhatsAppWebhookConfig config, Map raw, Map contactNames) { + Object idObj = raw.get("id"); + Object fromObj = raw.get("from"); + if (idObj == null || fromObj == null || inboxMessageRepository.existsByProviderMessageId(idObj.toString())) { + return; + } + String type = WhatsAppPayloads.textOr(raw.get("type"), "unknown"); + WhatsAppInboxMessage message = new WhatsAppInboxMessage(); + message.setConfig(config); + message.setCreatedBy(config.getCreatedBy()); + message.setFromNumber(fromObj.toString()); + message.setContactName(contactNames.get(fromObj.toString())); + message.setProviderMessageId(idObj.toString()); + message.setMessageType(type); + message.setReceivedAt(parseTimestamp(raw.get("timestamp"))); + if (MEDIA_MESSAGE_TYPES.contains(type)) { + attachMedia(message, config, WhatsAppPayloads.nestedMap(raw, type)); + } else { + message.setContent(extractInboundContent(raw, type)); + } + persist(message); + } + + private void persist(WhatsAppInboxMessage message) { + try { + inboxMessageRepository.save(message); + } catch (DataIntegrityViolationException e) { + log.debug("Duplicate WhatsApp inbound message [{}] ignored", message.getProviderMessageId()); + } + } + + private void attachMedia(WhatsAppInboxMessage message, WhatsAppWebhookConfig config, Map media) { + Object mediaId = media.get("id"); + String caption = WhatsAppPayloads.textOr(media.get("caption"), null); + message.setContent(caption); + if (mediaId == null) { + return; + } + message.setMediaId(mediaId.toString()); + message.setMimeType(WhatsAppPayloads.textOr(media.get("mime_type"), null)); + message.setCaption(caption); + mediaService.download(config, mediaId.toString()).ifPresent(downloaded -> { + message.setMediaPath(downloaded.path()); + message.setMediaSize(downloaded.size()); + }); + } + + private String extractInboundContent(Map raw, String type) { + Object body = "text".equals(type) ? WhatsAppPayloads.nestedMap(raw, "text").get("body") : null; + return body != null ? body.toString() : "[" + type + "]"; + } + + private LocalDateTime parseTimestamp(Object timestamp) { + try { + return LocalDateTime.ofEpochSecond(Long.parseLong(String.valueOf(timestamp)), 0, ZoneOffset.UTC); + } catch (NumberFormatException e) { + return LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppPayloads.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppPayloads.java new file mode 100644 index 0000000..e90fa8c --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppPayloads.java @@ -0,0 +1,100 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.webhook; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +/** Null-safe readers for Meta's webhook payload shape: {@code {entry: [{id, changes: [{field, value}]}]}}. */ +public final class WhatsAppPayloads { + public static final String VALUE = "value"; + public static final String MESSAGES = "messages"; + public static final String STATUSES = "statuses"; + public static final String STATUS = "status"; + public static final String TEMPLATE_STATUS_FIELD = "message_template_status_update"; + private static final String UNKNOWN = "unknown"; + private static final int VISIBLE_PHONE_DIGITS = 4; + + private WhatsAppPayloads() { + } + + @SuppressWarnings("unchecked") + public static Map nestedMap(Map source, String key) { + Object value = source.get(key); + return value instanceof Map map ? (Map) map : Map.of(); + } + + @SuppressWarnings("unchecked") + public static List> mapList(Map source, String key) { + Object items = source.get(key); + if (!(items instanceof List list)) { + return List.of(); + } + return list.stream().filter(Map.class::isInstance).map(item -> (Map) item).toList(); + } + + public static Map firstOf(Map value, String key) { + List> items = mapList(value, key); + return items.isEmpty() ? Map.of() : items.get(0); + } + + public static List> entries(Map payload) { + return mapList(payload, "entry"); + } + + public static List> changes(Map payload) { + return entries(payload).stream().flatMap(entry -> mapList(entry, "changes").stream()).toList(); + } + + public static Map value(Map change) { + return nestedMap(change, VALUE); + } + + /** {@code value.metadata.phone_number_id} of one change; absent for WABA-level events. */ + public static Optional phoneNumberId(Map change) { + return text(nestedMap(value(change), "metadata").get("phone_number_id")); + } + + /** {@code value.metadata.display_phone_number} of the first change that carries one. */ + public static Optional displayPhoneNumberOfPayload(Map payload) { + return changes(payload).stream() + .map(change -> text(nestedMap(value(change), "metadata").get("display_phone_number"))) + .flatMap(Optional::stream).findFirst(); + } + + public static Optional phoneNumberIdOfPayload(Map payload) { + return changes(payload).stream().map(WhatsAppPayloads::phoneNumberId).flatMap(Optional::stream).findFirst(); + } + + public static Optional text(Object value) { + return Optional.ofNullable(value).map(Object::toString).filter(text -> !text.isBlank()); + } + + public static String textOr(Object value, String fallback) { + return value == null ? fallback : value.toString(); + } + + public static String maskPhone(Object phoneNumber) { + if (phoneNumber == null) { + return UNKNOWN; + } + String value = phoneNumber.toString(); + return value.length() <= VISIBLE_PHONE_DIGITS ? value : "•••• " + value.substring(value.length() - VISIBLE_PHONE_DIGITS); + } + + public static String truncate(String value, int max) { + return value == null || value.length() <= max ? value : value.substring(0, max); + } + + /** "[code] message" from the first entry of a status's {@code errors}; null when there is none. */ + public static String extractErrorMessage(Map raw) { + Map first = firstOf(raw, "errors"); + if (first.isEmpty()) { + return null; + } + String text = Stream.of(first.get("message"), first.get("title")) + .flatMap(item -> text(item).stream()).findFirst().orElse("WhatsApp delivery failed"); + Object code = first.get("code"); + return code != null ? "[" + code + "] " + text : text; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppSharedWebhookRouter.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppSharedWebhookRouter.java new file mode 100644 index 0000000..1976b89 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppSharedWebhookRouter.java @@ -0,0 +1,116 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.webhook; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppMetaApp; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Splits one delivery on a Meta app's shared callback URL into per-config deliveries. + * + *

Meta sends every WABA and phone number subscribed to an app to the same URL, batching + * changes from several numbers into one POST. Each change is routed by + * {@code value.metadata.phone_number_id}; WABA-level changes that carry no phone number + * (template status, account updates) go to every config of that WABA ({@code entry[].id}), or to + * the app's only config when there is just one. Only configs linked to this app are candidates, + * so a shared URL can never reach another app's (or tenant's) configuration. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WhatsAppSharedWebhookRouter { + private final WhatsAppWebhookConfigRepository configRepository; + + /** A config together with the subset of the delivery addressed to it. */ + public record RoutedDelivery(WhatsAppWebhookConfig config, Map payload) { + } + + public List route(WhatsAppMetaApp app, Map payload) { + Candidates candidates = new Candidates(configRepository.findAllActiveByMetaAppId(app.getId())); + Map>> entriesByConfig = new LinkedHashMap<>(); + for (Map entry : WhatsAppPayloads.entries(payload)) { + String wabaId = WhatsAppPayloads.text(entry.get("id")).orElse(null); + for (Map change : WhatsAppPayloads.mapList(entry, "changes")) { + candidates.targetsOf(change, wabaId).forEach(config -> entriesByConfig + .computeIfAbsent(config.getId(), id -> new ArrayList<>()).add(singleChangeEntry(wabaId, change))); + } + } + return entriesByConfig.entrySet().stream() + .map(routed -> new RoutedDelivery(candidates.byId(routed.getKey()), payloadOf(payload, routed.getValue()))) + .toList(); + } + + private static Map singleChangeEntry(String wabaId, Map change) { + Map entry = new HashMap<>(); + entry.put("id", wabaId); + entry.put("changes", List.of(change)); + return entry; + } + + private static Map payloadOf(Map original, List> entries) { + Map routed = new LinkedHashMap<>(); + routed.put("object", original.getOrDefault("object", "whatsapp_business_account")); + routed.put("entry", entries); + return routed; + } + + private static final class Candidates { + private final List configs; + private final Map byPhoneNumberId; + private final Map byConfigId; + + Candidates(List configs) { + this.configs = configs; + this.byPhoneNumberId = configs.stream().collect( + Collectors.toMap(WhatsAppWebhookConfig::getPhoneNumberId, Function.identity(), (first, second) -> first)); + this.byConfigId = configs.stream().collect(Collectors.toMap(WhatsAppWebhookConfig::getId, Function.identity())); + } + + WhatsAppWebhookConfig byId(UUID id) { + return byConfigId.get(id); + } + + List targetsOf(Map change, String wabaId) { + Optional phoneNumberId = WhatsAppPayloads.phoneNumberId(change); + if (phoneNumberId.isPresent()) { + return byPhoneNumber(phoneNumberId.get()); + } + return byBusinessAccount(wabaId); + } + + private List byPhoneNumber(String phoneNumberId) { + WhatsAppWebhookConfig config = byPhoneNumberId.get(phoneNumberId); + if (config == null) { + log.warn("Ignoring shared WhatsApp webhook change for unregistered phone number [{}]", phoneNumberId); + return List.of(); + } + return List.of(config); + } + + private List byBusinessAccount(String wabaId) { + List matches = configs.stream() + .filter(config -> wabaId != null && Objects.equals(wabaId, config.getBusinessAccountId())) + .toList(); + if (matches.isEmpty() && configs.size() == 1) { + return configs; + } + if (matches.isEmpty()) { + log.warn("Ignoring shared WhatsApp webhook change for WABA [{}]: set businessAccountId on its configuration", wabaId); + } + return matches; + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppStatusUpdater.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppStatusUpdater.java new file mode 100644 index 0000000..84bd6ee --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppStatusUpdater.java @@ -0,0 +1,111 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.webhook; + +import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; +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.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppTemplateRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +import static com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads.STATUS; +import static com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads.STATUSES; +import static com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads.TEMPLATE_STATUS_FIELD; + +/** Applies Meta message-status callbacks to SmsLogs and template review events to WhatsAppTemplates. */ +@Component +@RequiredArgsConstructor +public class WhatsAppStatusUpdater { + private static final Map STATUS_BY_META_VALUE = Map.of( + "read", SmsLogStatus.READ, + "delivered", SmsLogStatus.DELIVERED, + "failed", SmsLogStatus.FAILED, + "sent", SmsLogStatus.SENT); + + // 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 + // a step past it. + private static final List DELIVERY_PROGRESSION = List.of(SmsLogStatus.SENT, SmsLogStatus.DELIVERED, SmsLogStatus.READ); + + private final SmsLogRepository smsLogRepository; + private final WhatsAppTemplateRepository templateRepository; + + public void updateDeliveryStatus(Map payload) { + WhatsAppPayloads.changes(payload).stream() + .flatMap(change -> WhatsAppPayloads.mapList(WhatsAppPayloads.value(change), STATUSES).stream()) + .forEach(this::applyStatusUpdate); + } + + private void applyStatusUpdate(Map raw) { + Object id = raw.get("id"); + SmsLogStatus next = STATUS_BY_META_VALUE.get(String.valueOf(raw.get(STATUS)).toLowerCase(Locale.ROOT)); + if (id == null || next == null) { + return; + } + smsLogRepository.findByProviderMessageId(id.toString()).ifPresent(logEntry -> applyStatus(logEntry, next, raw)); + } + + private void applyStatus(SmsLog logEntry, SmsLogStatus next, Map raw) { + if (next != SmsLogStatus.FAILED && isDeliveryRegression(logEntry.getStatus(), next)) { + return; + } + logEntry.setStatus(next); + if (next == SmsLogStatus.FAILED) { + logEntry.setError(WhatsAppPayloads.extractErrorMessage(raw)); + } + smsLogRepository.save(logEntry); + } + + private boolean isDeliveryRegression(SmsLogStatus current, SmsLogStatus next) { + // List.of(...)'s indexOf() throws NPE on a null argument, and current is null for a log + // whose status hasn't been set yet, so this must short-circuit first. + if (current == null) { + return false; + } + int currentIndex = DELIVERY_PROGRESSION.indexOf(current); + int nextIndex = DELIVERY_PROGRESSION.indexOf(next); + return currentIndex >= 0 && nextIndex >= 0 && nextIndex < currentIndex; + } + + /** Updates the matching local WhatsAppTemplate.status (by metaTemplateId, falling back to + * name+language) so an approval/rejection shows up immediately instead of waiting for the next + * manual sync. Both lookups are scoped to config's owner -- metaTemplateId and name+language + * are each only unique per (value, creator), so an unscoped lookup could corrupt a different + * tenant's template of the same id or common name (e.g. "otp_verification"). */ + public void updateTemplateStatus(WhatsAppWebhookConfig config, Map payload) { + WhatsAppPayloads.changes(payload).stream() + .filter(change -> TEMPLATE_STATUS_FIELD.equals(change.get("field"))) + .map(WhatsAppPayloads::value) + .filter(value -> value.get("event") != null) + .forEach(value -> findTemplate(config, value).ifPresent(template -> saveTemplateStatus(template, value))); + } + + private Optional findTemplate(WhatsAppWebhookConfig config, Map value) { + Optional byId = WhatsAppPayloads.text(value.get("message_template_id")) + .flatMap(id -> templateRepository.findFirstByMetaTemplateIdAndCreatedBy(id, config.getCreatedBy())); + return byId.isPresent() ? byId : findTemplateByNameAndLanguage(config, value); + } + + private Optional findTemplateByNameAndLanguage(WhatsAppWebhookConfig config, Map value) { + Object name = value.get("message_template_name"); + Object language = value.get("message_template_language"); + if (name == null || language == null) { + return Optional.empty(); + } + return templateRepository.findFirstByNameAndLanguageAndCreatedBy(name.toString(), language.toString(), config.getCreatedBy()); + } + + private void saveTemplateStatus(WhatsAppTemplate template, Map value) { + template.setStatus(value.get("event").toString()); + template.setLastSyncedAt(LocalDateTime.now()); + templateRepository.save(template); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppWebhookEventRecorder.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppWebhookEventRecorder.java new file mode 100644 index 0000000..d4ec47e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppWebhookEventRecorder.java @@ -0,0 +1,116 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.webhook; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookEvent; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads.MESSAGES; +import static com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads.STATUS; +import static com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads.STATUSES; +import static com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads.TEMPLATE_STATUS_FIELD; + +/** Persists every change of a webhook delivery to the event log (one row per change). */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WhatsAppWebhookEventRecorder { + private static final int SUMMARY_MAX_LENGTH = 500; + + private final WhatsAppWebhookEventRepository eventRepository; + private final ObjectMapper objectMapper; + + /** Best-effort: a logging failure must never stop the delivery from being processed or acknowledged. */ + public void record(WhatsAppWebhookConfig config, Map payload) { + for (Map change : WhatsAppPayloads.changes(payload)) { + try { + eventRepository.save(toEvent(config, change)); + } catch (JsonProcessingException | RuntimeException e) { + log.warn("Unable to record WhatsApp webhook event for config [{}]: {}", config.getId(), e.getMessage()); + } + } + } + + private WhatsAppWebhookEvent toEvent(WhatsAppWebhookConfig config, Map change) throws JsonProcessingException { + Map value = WhatsAppPayloads.value(change); + String field = fieldOf(change, value); + WhatsAppWebhookEvent event = new WhatsAppWebhookEvent(); + event.setConfig(config); + event.setCreatedBy(config.getCreatedBy()); + event.setField(field); + event.setEventType(eventType(field, value)); + event.setSummary(WhatsAppPayloads.truncate(eventSummary(field, value), SUMMARY_MAX_LENGTH)); + event.setPayload(objectMapper.writeValueAsString(change)); + event.setReceivedAt(LocalDateTime.now()); + return event; + } + + private String fieldOf(Map change, Map value) { + if (change.get("field") != null) { + return change.get("field").toString(); + } + return value.containsKey(MESSAGES) || value.containsKey(STATUSES) ? MESSAGES : "unknown"; + } + + String eventType(String field, Map value) { + if (MESSAGES.equals(field)) { + return messagesEventType(value, field); + } + if (TEMPLATE_STATUS_FIELD.equals(field) && value.get("event") != null) { + return "template." + value.get("event"); + } + return field; + } + + private String messagesEventType(Map value, String field) { + if (value.get(MESSAGES) instanceof List) { + return "message.received"; + } + Object status = WhatsAppPayloads.firstOf(value, STATUSES).get(STATUS); + if (status != null) { + return "status." + status; + } + return value.get("errors") instanceof List ? "message.error" : field; + } + + String eventSummary(String field, Map value) { + if (MESSAGES.equals(field)) { + return messagesSummary(value, field); + } + if (TEMPLATE_STATUS_FIELD.equals(field)) { + return templateSummary(value); + } + return field; + } + + private String messagesSummary(Map value, String field) { + List> messages = WhatsAppPayloads.mapList(value, MESSAGES); + if (!messages.isEmpty()) { + Map first = messages.get(0); + String summary = "Inbound " + first.getOrDefault("type", "message") + " from " + WhatsAppPayloads.maskPhone(first.get("from")); + return messages.size() > 1 ? summary + " (+" + (messages.size() - 1) + " more)" : summary; + } + Map status = WhatsAppPayloads.firstOf(value, STATUSES); + return status.get(STATUS) != null ? statusSummary(status) : field; + } + + private String statusSummary(Map status) { + String error = WhatsAppPayloads.extractErrorMessage(status); + String summary = "Message to " + WhatsAppPayloads.maskPhone(status.get("recipient_id")) + " " + status.get(STATUS); + return error != null ? summary + ": " + error : summary; + } + + private String templateSummary(Map value) { + String summary = value.get("message_template_name") + " (" + value.get("message_template_language") + ") " + value.get("event"); + Object reason = value.get("reason"); + return reason != null && !"NONE".equals(reason.toString()) ? summary + ": " + reason : summary; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppWebhookProcessor.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppWebhookProcessor.java new file mode 100644 index 0000000..80b0963 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/webhook/WhatsAppWebhookProcessor.java @@ -0,0 +1,98 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.webhook; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppMetaApp; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppRelayDelivery; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.helpers.HmacUtil; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppRelayDeliveryRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.Map; + +/** Applies one signature-valid Meta delivery to a single webhook configuration. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WhatsAppWebhookProcessor { + private static final String SIGNATURE_PREFIX = "sha256="; + + private final WhatsAppWebhookConfigRepository configRepository; + private final WhatsAppRelayDeliveryRepository relayDeliveryRepository; + private final WhatsAppWebhookEventRecorder eventRecorder; + private final WhatsAppStatusUpdater statusUpdater; + private final WhatsAppInboundMessageIngestor inboundMessageIngestor; + private final ObjectMapper objectMapper; + + public void process(WhatsAppWebhookConfig config, Map payload) { + config.setLastEventAt(LocalDateTime.now()); + if (config.getDisplayPhoneNumber() == null) { + WhatsAppPayloads.displayPhoneNumberOfPayload(payload).ifPresent(config::setDisplayPhoneNumber); + } + configRepository.save(config); + eventRecorder.record(config, payload); + statusUpdater.updateDeliveryStatus(payload); + inboundMessageIngestor.ingest(config, payload); + statusUpdater.updateTemplateStatus(config, payload); + relay(config, payload); + log.info("Processed WhatsApp webhook for config [{}]: {} change(s)", config.getId(), WhatsAppPayloads.changes(payload).size()); + } + + /** The config's own app secret wins; otherwise a linked Meta app's secret is used, since a + * number subscribed to that app is signed with it on every callback URL. */ + public boolean validSignature(WhatsAppWebhookConfig config, String rawPayload, String signature) { + String secret = isBlank(config.getAppSecret()) ? metaAppSecret(config.getMetaApp()) : config.getAppSecret(); + return validSignature(secret, rawPayload, signature); + } + + public boolean validSignature(WhatsAppMetaApp app, String rawPayload, String signature) { + return validSignature(app.getAppSecret(), rawPayload, signature); + } + + private boolean validSignature(String secret, String rawPayload, String signature) { + if (isBlank(secret)) { + return true; + } + if (signature == null || !signature.startsWith(SIGNATURE_PREFIX)) { + return false; + } + try { + String expected = SIGNATURE_PREFIX + HmacUtil.sha256Hex(rawPayload, secret); + return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), signature.getBytes(StandardCharsets.US_ASCII)); + } catch (GeneralSecurityException e) { + log.warn("Unable to compute WhatsApp webhook signature: {}", e.getMessage()); + return false; + } + } + + private void relay(WhatsAppWebhookConfig config, Map payload) { + if (isBlank(config.getCallbackUrl())) { + return; + } + try { + WhatsAppRelayDelivery delivery = new WhatsAppRelayDelivery(); + delivery.setConfig(config); + delivery.setCreatedBy(config.getCreatedBy()); + delivery.setPayload(objectMapper.writeValueAsString(payload)); + relayDeliveryRepository.save(delivery); + } catch (JsonProcessingException | RuntimeException e) { + log.warn("Unable to queue WhatsApp relay for config [{}]: {}", config.getId(), e.getMessage()); + } + } + + private static String metaAppSecret(WhatsAppMetaApp app) { + return app == null ? null : app.getAppSecret(); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 808a456..86621ec 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -52,6 +52,14 @@ spring.web.error.include-message=${ERROR_INCLUDE_MESSAGE:always} # SMS Pricing flextuma.sms.price-per-segment=${SMS_PRICE_PER_SEGMENT:20.0} +# Outbound message and webhook relay retries (Kafka retry-topic style): every error is +# retried; attempt N waits initial-interval * multiplier^(N-1), capped at max-interval, +# until window-ms (default 12h) after the first failure, then the message is marked FAILED. +flextuma.messaging.retry.initial-interval-ms=${FLEXTUMA_MESSAGING_RETRY_INITIAL_INTERVAL_MS:10000} +flextuma.messaging.retry.multiplier=${FLEXTUMA_MESSAGING_RETRY_MULTIPLIER:2.0} +flextuma.messaging.retry.max-interval-ms=${FLEXTUMA_MESSAGING_RETRY_MAX_INTERVAL_MS:3600000} +flextuma.messaging.retry.window-ms=${FLEXTUMA_MESSAGING_RETRY_WINDOW_MS:43200000} + # Beem delivery reports flextuma.sms.beem.delivery-poll-interval-ms=${FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS:60000} flextuma.sms.beem.delivery-minimum-delay-minutes=${FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES:5} @@ -89,6 +97,12 @@ flextuma.whatsapp.media.directory=${FLEXTUMA_WHATSAPP_MEDIA_DIRECTORY:client/med flextuma.whatsapp.media-backfill.interval-ms=${FLEXTUMA_WHATSAPP_MEDIA_BACKFILL_INTERVAL_MS:300000} flextuma.whatsapp.media-backfill.batch-size=${FLEXTUMA_WHATSAPP_MEDIA_BACKFILL_BATCH_SIZE:25} flextuma.whatsapp.media-backfill.max-age-days=${FLEXTUMA_WHATSAPP_MEDIA_BACKFILL_MAX_AGE_DAYS:3} +# WhatsApp webhook event log retention: platform default and cap. Each webhook config may set its +# own eventRetentionDays, and each account can set one in Settings → Data retention +# (1..max; 0 = inherit). Precedence: webhook, account, platform default. Purged nightly. +flextuma.whatsapp.events.retention-days=${FLEXTUMA_WHATSAPP_EVENTS_RETENTION_DAYS:30} +flextuma.whatsapp.events.max-retention-days=${FLEXTUMA_WHATSAPP_EVENTS_MAX_RETENTION_DAYS:365} +flextuma.whatsapp.events.purge-cron=${FLEXTUMA_WHATSAPP_EVENTS_PURGE_CRON:0 30 2 * * *} # Upload size limits. These can be overridden per deployment through environment variables. spring.servlet.multipart.max-file-size=${MAX_UPLOAD_FILE_SIZE:100MB} 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 6e46143..89a2ea8 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 @@ -25,6 +25,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.InjectMocks; +import org.mockito.Spy; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -81,6 +82,9 @@ class NotificationServiceTest { @Mock private WhatsAppSender whatsAppSender; + @Spy + private MessageRetryPolicy retryPolicy = new MessageRetryPolicy(10_000, 2.0, 3_600_000, 43_200_000); + @InjectMocks private NotificationService notificationService; @@ -178,7 +182,7 @@ void queueTemplatedSms_shouldThrowWhenConnectorNotFound() { when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) + when(connectorRepository.findPreferredActiveConnector(testUser, "Twilio")) .thenReturn(Optional.empty()); ResponseStatusException ex = assertThrows(ResponseStatusException.class, @@ -199,7 +203,7 @@ void queueTemplatedSms_shouldQueueSmsSuccessfully() { when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); when(templateRepository.findByCreatedByAndCode(testUser, "WELCOME")).thenReturn(Optional.of(template)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "Twilio")) + when(connectorRepository.findPreferredActiveConnector(testUser, "Twilio")) .thenReturn(Optional.of(connector)); SmsSegmentResult mockSegmentResult = new SmsSegmentResult(1, true, 14, 146, BigDecimal.ONE, @@ -236,7 +240,7 @@ void queueRawSms_shouldDebitWalletAtPerSegmentPriceForSystemConnector() { systemConnector.setCode("BEEM_SYSTEM"); when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "BEEM")).thenReturn(Optional.empty()); + when(connectorRepository.findPreferredActiveConnector(testUser, "BEEM")).thenReturn(Optional.empty()); when(connectorRepository.findByProviderAndCode("BEEM", "BEEM_SYSTEM")).thenReturn(Optional.of(systemConnector)); when(segmentCalculator.calculate(anyString())).thenReturn(new SmsSegmentResult(2, true, 0, 0, BigDecimal.ONE, BigDecimal.ONE)); when(logRepository.save(any(SmsLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); @@ -282,7 +286,7 @@ void queueWhatsAppTemplate_shouldRejectWhenNoSyncedTemplateMatches() { connector.setCreatedBy(testUser); when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + when(connectorRepository.findPreferredActiveConnector(testUser, "WHATSAPP")) .thenReturn(Optional.of(connector)); when(whatsAppTemplateRepository.findByNameAndLanguageAndConnectorAndCreatedBy("farm_alert", "en", connector, testUser)).thenReturn(Optional.empty()); @@ -304,7 +308,7 @@ void queueWhatsAppTemplate_shouldRejectWhenTemplateNotApproved() { template.setStatus("PENDING"); when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + when(connectorRepository.findPreferredActiveConnector(testUser, "WHATSAPP")) .thenReturn(Optional.of(connector)); when(whatsAppTemplateRepository.findByNameAndLanguageAndConnectorAndCreatedBy("farm_alert", "en", connector, testUser)).thenReturn(Optional.of(template)); @@ -327,7 +331,7 @@ void queueWhatsAppTemplate_shouldSendAndSaveSentLogOnSuccess() { template.setStatus("APPROVED"); when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + when(connectorRepository.findPreferredActiveConnector(testUser, "WHATSAPP")) .thenReturn(Optional.of(connector)); when(whatsAppTemplateRepository.findByNameAndLanguageAndConnectorAndCreatedBy("farm_alert", "en", connector, testUser)).thenReturn(Optional.of(template)); @@ -349,8 +353,8 @@ void queueWhatsAppTemplate_shouldSendAndSaveSentLogOnSuccess() { verifyNoInteractions(walletService); } - @Test - void queueWhatsAppTemplate_shouldSaveFailedLogWhenSenderFails() { + @Test + void queueWhatsAppTemplate_shouldScheduleBackoffRetryWhenSenderFails() { SmsConnector connector = new SmsConnector(); connector.setProvider("WHATSAPP"); connector.setCreatedBy(testUser); @@ -358,22 +362,28 @@ void queueWhatsAppTemplate_shouldSaveFailedLogWhenSenderFails() { WhatsAppTemplate template = new WhatsAppTemplate(); template.setStatus("APPROVED"); + SmsSendResult failure = SmsSendResult.failure("Meta rejected the template", "131047", Map.of()); + when(userRepository.findByUsername("testuser")).thenReturn(Optional.of(testUser)); - when(connectorRepository.findByCreatedByAndProviderAndActiveTrue(testUser, "WHATSAPP")) + when(connectorRepository.findPreferredActiveConnector(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())); + .thenReturn(failure); when(logRepository.save(any(SmsLog.class))).thenAnswer(invocation -> invocation.getArgument(0)); - SmsLog result = notificationService.queueWhatsAppTemplate(validTemplatePayload(), "testuser"); + notificationService.queueWhatsAppTemplate(validTemplatePayload(), "testuser"); verify(logRepository).save(smsLogCaptor.capture()); SmsLog capturedLog = smsLogCaptor.getValue(); - assertEquals(SmsLogStatus.FAILED, capturedLog.getStatus()); + assertEquals(SmsLogStatus.PENDING, capturedLog.getStatus()); + assertEquals(1, capturedLog.getDeliveryAttempts()); + assertEquals(1, capturedLog.getRetries()); + assertNotNull(capturedLog.getNextRetryAt()); assertEquals("Meta rejected the template", capturedLog.getError()); - assertNotNull(result); + assertEquals("farm_alert", capturedLog.getWhatsappTemplate().get("name")); + assertEquals("en", capturedLog.getWhatsappTemplate().get("language")); verifyNoInteractions(walletService); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java index 2511a71..fe116ec 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/notification/services/SmsDispatchWorkerTest.java @@ -4,6 +4,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.Map; @@ -18,6 +19,7 @@ import com.flexcodelabs.flextuma.core.entities.sms.SmsLog; import com.flexcodelabs.flextuma.core.enums.SmsLogStatus; import com.flexcodelabs.flextuma.core.repositories.SmsLogRepository; +import com.flexcodelabs.flextuma.core.senders.WhatsAppSender; import com.flexcodelabs.flextuma.core.services.SmsSendResult; import com.flexcodelabs.flextuma.core.services.SmsSender; @@ -30,11 +32,32 @@ class SmsDispatchWorkerTest { @Mock private SmsSender smsSender; + @Mock + private WhatsAppSender whatsAppSender; + + @Mock + private NotificationService notificationService; + private SmsDispatchWorker worker; @BeforeEach void setUp() { - worker = new SmsDispatchWorker(logRepository, List.of(smsSender)); + MessageRetryPolicy retryPolicy = new MessageRetryPolicy(10_000, 2.0, 3_600_000, 43_200_000); + worker = new SmsDispatchWorker(logRepository, List.of(smsSender), whatsAppSender, notificationService, + retryPolicy); + } + + private void stubDue(SmsLog log) { + when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(LocalDateTime.class), + any(org.springframework.data.domain.Pageable.class))) + .thenReturn(List.of(log)); + when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + when(logRepository.claimPendingMessage(any(), eq(SmsLogStatus.PENDING), eq(SmsLogStatus.PROCESSING))) + .thenReturn(1); + } + + private static SmsSendResult transientFailure() { + return SmsSendResult.failure("503 Service Unavailable", "SEND_ERROR", Map.of()); } private SmsLog pendingLog(String provider) { @@ -71,39 +94,124 @@ void dispatch_shouldMarkSent_whenSendSucceeds() { } @Test - void dispatch_shouldRetry_whenSendFailsAndRetriesBelow3() { + void dispatch_shouldScheduleBackoffRetry_whenFailureIsTransient() { SmsLog log = pendingLog("beem"); when(smsSender.getProvider()).thenReturn("beem"); - when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), - any(org.springframework.data.domain.Pageable.class))) - .thenReturn(List.of(log)); - when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); - when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(logRepository.claimPendingMessage(any(), eq(SmsLogStatus.PENDING), eq(SmsLogStatus.PROCESSING))).thenReturn(1); + when(smsSender.sendSms(any(), any(), any())).thenReturn(transientFailure()); + stubDue(log); + LocalDateTime before = LocalDateTime.now(); worker.dispatch(); assertEquals(SmsLogStatus.PENDING, log.getStatus()); assertEquals(1, log.getRetries()); + assertEquals(1, log.getDeliveryAttempts()); + assertEquals("503 Service Unavailable", log.getError()); + assertFalse(log.getNextRetryAt().isBefore(before.plusSeconds(10))); } @Test - void dispatch_shouldMarkFailed_whenMaxRetriesReached() { + void dispatch_shouldBackOffExponentially_onLaterAttempts() { SmsLog log = pendingLog("beem"); - log.setRetries(2); + log.setDeliveryAttempts(1); + log.setRetries(1); + when(smsSender.getProvider()).thenReturn("beem"); + when(smsSender.sendSms(any(), any(), any())).thenReturn(transientFailure()); + stubDue(log); + + LocalDateTime before = LocalDateTime.now(); + worker.dispatch(); + assertEquals(SmsLogStatus.PENDING, log.getStatus()); + assertEquals(2, log.getRetries()); + assertFalse(log.getNextRetryAt().isBefore(before.plusSeconds(20))); + assertTrue(log.getNextRetryAt().isBefore(before.plusSeconds(40))); + } + + @Test + void dispatch_shouldKeepRetrying_whileInsideRetryWindow() { + SmsLog log = pendingLog("beem"); + log.setDeliveryAttempts(20); + log.setRetries(20); + log.setRetryWindowStartedAt(LocalDateTime.now().minusHours(11)); when(smsSender.getProvider()).thenReturn("beem"); - when(logRepository.findDueMessages(eq(SmsLogStatus.PENDING), any(java.time.LocalDateTime.class), - any(org.springframework.data.domain.Pageable.class))) - .thenReturn(List.of(log)); - when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); - when(logRepository.save(any())).thenAnswer(i -> i.getArgument(0)); - when(logRepository.claimPendingMessage(any(), eq(SmsLogStatus.PENDING), eq(SmsLogStatus.PROCESSING))).thenReturn(1); + when(smsSender.sendSms(any(), any(), any())).thenReturn(transientFailure()); + stubDue(log); + + LocalDateTime before = LocalDateTime.now(); + worker.dispatch(); + + assertEquals(SmsLogStatus.PENDING, log.getStatus()); + assertEquals(21, log.getRetries()); + // Backoff is capped at max-interval (1h). + assertFalse(log.getNextRetryAt().isBefore(before.plusHours(1))); + assertTrue(log.getNextRetryAt().isBefore(before.plusHours(1).plusSeconds(5))); + } + + @Test + void dispatch_shouldMarkFailed_whenRetryWindowExhausted() { + SmsLog log = pendingLog("beem"); + log.setDeliveryAttempts(20); + log.setRetries(20); + log.setRetryWindowStartedAt(LocalDateTime.now().minusHours(12).minusMinutes(1)); + when(smsSender.getProvider()).thenReturn("beem"); + when(smsSender.sendSms(any(), any(), any())).thenReturn(transientFailure()); + stubDue(log); worker.dispatch(); assertEquals(SmsLogStatus.FAILED, log.getStatus()); - assertEquals(3, log.getRetries()); + assertEquals(21, log.getDeliveryAttempts()); + assertEquals(20, log.getRetries()); + assertNull(log.getNextRetryAt()); + } + + @Test + void dispatch_shouldRetry_whenProviderRejectsWith4xx() { + SmsLog log = pendingLog("beem"); + when(smsSender.getProvider()).thenReturn("beem"); + when(smsSender.sendSms(any(), any(), any())) + .thenReturn(SmsSendResult.failure("400 Bad Request", "SEND_ERROR", Map.of())); + stubDue(log); + + worker.dispatch(); + + assertEquals(SmsLogStatus.PENDING, log.getStatus()); + assertEquals(1, log.getRetries()); + assertNotNull(log.getRetryWindowStartedAt()); + assertNotNull(log.getNextRetryAt()); + } + + @Test + void dispatch_shouldRetry_whenSenderThrows() { + SmsLog log = pendingLog("beem"); + when(smsSender.getProvider()).thenReturn("beem"); + when(smsSender.sendSms(any(), any(), any())).thenThrow(new RuntimeException("timeout")); + stubDue(log); + + worker.dispatch(); + + assertEquals(SmsLogStatus.PENDING, log.getStatus()); + assertEquals(1, log.getRetries()); + assertEquals("timeout", log.getError()); + } + + @Test + void dispatch_shouldResendWhatsAppTemplate_andBillIt() { + SmsLog log = pendingLog("WHATSAPP"); + log.setContent("[Template: farm_alert/en] Feed is low"); + log.setWhatsappTemplate(Map.of("name", "farm_alert", "language", "en", "components", List.of())); + when(whatsAppSender.sendTemplate(any(), eq("255700000000"), eq("farm_alert"), eq("en"), any())) + .thenReturn(SmsSendResult.success("accepted", "wamid.1", Map.of())); + stubDue(log); + + worker.dispatch(); + + assertEquals(SmsLogStatus.SENT, log.getStatus()); + assertEquals("wamid.1", log.getProviderMessageId()); + verify(smsSender, never()).sendSms(any(), any(), any()); + verify(notificationService).chargeTemplateSend(any(), eq(log.getConnector()), + eq("[Template: farm_alert/en] Feed is low"), eq("255700000000")); } @Test diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/retention/services/DataRetentionPolicyServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/retention/services/DataRetentionPolicyServiceTest.java new file mode 100644 index 0000000..f322878 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/retention/services/DataRetentionPolicyServiceTest.java @@ -0,0 +1,110 @@ +package com.flexcodelabs.flextuma.modules.retention.services; + +import com.flexcodelabs.flextuma.core.entities.auth.Organisation; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.settings.DataRetentionPolicy; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.DataRetentionPolicyRepository; +import com.flexcodelabs.flextuma.modules.retention.dtos.DataRetentionPolicyDTO; +import com.flexcodelabs.flextuma.modules.retention.dtos.DataRetentionPolicyUpdateDTO; +import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppEventRetentionPolicy; +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 java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DataRetentionPolicyServiceTest { + + @Mock + private DataRetentionPolicyRepository repository; + + @Mock + private CurrentUserResolver currentUserResolver; + + private DataRetentionPolicyService service; + + @BeforeEach + void setUp() { + service = new DataRetentionPolicyService(repository, currentUserResolver, new WhatsAppEventRetentionPolicy(30, 90)); + } + + @Test + void getForCurrentUser_shouldReturnPlatformDefaults_whenNoPolicyExists() { + User user = user(null); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(repository.findByOwner(user)).thenReturn(Optional.empty()); + + DataRetentionPolicyDTO dto = service.getForCurrentUser(); + + assertEquals("USER", dto.scope()); + assertNull(dto.whatsappEventRetentionDays()); + assertEquals(30, dto.whatsappEventDefaultDays()); + assertEquals(90, dto.whatsappEventMaxDays()); + assertEquals(30, dto.whatsappEventEffectiveDays()); + } + + @Test + void updateForCurrentUser_shouldCreateOrganisationScopedPolicy_whenUserHasOrganisation() { + Organisation organisation = new Organisation(); + organisation.setId(UUID.randomUUID()); + User user = user(organisation); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(repository.findByOrganisation(organisation)).thenReturn(Optional.empty()); + + DataRetentionPolicyDTO dto = service.updateForCurrentUser(new DataRetentionPolicyUpdateDTO(14)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DataRetentionPolicy.class); + verify(repository).save(captor.capture()); + assertSame(organisation, captor.getValue().getOrganisation()); + assertNull(captor.getValue().getOwner()); + assertEquals(14, captor.getValue().getWhatsappEventRetentionDays()); + assertEquals("ORGANISATION", dto.scope()); + assertEquals(14, dto.whatsappEventEffectiveDays()); + } + + @Test + void updateForCurrentUser_shouldResetToDefault_whenZero() { + User user = user(null); + DataRetentionPolicy existing = new DataRetentionPolicy(); + existing.setOwner(user); + existing.setWhatsappEventRetentionDays(14); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user)); + when(repository.findByOwner(user)).thenReturn(Optional.of(existing)); + + DataRetentionPolicyDTO dto = service.updateForCurrentUser(new DataRetentionPolicyUpdateDTO(0)); + + assertNull(existing.getWhatsappEventRetentionDays()); + assertEquals(30, dto.whatsappEventEffectiveDays()); + } + + @Test + void updateForCurrentUser_shouldRejectAboveMaximum() { + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(user(null))); + + assertThrows(IllegalArgumentException.class, + () -> service.updateForCurrentUser(new DataRetentionPolicyUpdateDTO(91))); + verify(repository, never()).save(any()); + } + + private User user(Organisation organisation) { + User user = new User(); + user.setId(UUID.randomUUID()); + user.setOrganisation(organisation); + return user; + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java index c8f752e..65d7448 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java @@ -63,6 +63,9 @@ void retryFailedMessage_Success() { utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) .thenReturn(Set.of("SUPER_ADMIN")); + smsLog.setDeliveryAttempts(5); + smsLog.setNextRetryAt(java.time.LocalDateTime.now()); + smsLog.setRetryWindowStartedAt(java.time.LocalDateTime.now().minusHours(13)); when(smsLogRepository.findById(logId)).thenReturn(Optional.of(smsLog)); when(smsLogRepository.save(any(SmsLog.class))).thenAnswer(i -> i.getArgument(0)); @@ -71,6 +74,9 @@ void retryFailedMessage_Success() { assertNotNull(retriedLog); assertEquals(SmsLogStatus.PENDING, retriedLog.getStatus()); assertEquals(2, retriedLog.getRetries()); + assertEquals(0, retriedLog.getDeliveryAttempts()); + assertNull(retriedLog.getNextRetryAt()); + assertNull(retriedLog.getRetryWindowStartedAt()); assertNull(retriedLog.getError()); assertNull(retriedLog.getProviderResponse()); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppSharedWebhookControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppSharedWebhookControllerTest.java new file mode 100644 index 0000000..14c162a --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppSharedWebhookControllerTest.java @@ -0,0 +1,243 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.controllers; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppMetaApp; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppRelayDelivery; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +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.WhatsAppMetaAppRepository; +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.core.repositories.WhatsAppWebhookEventRepository; +import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppMediaService; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppInboundMessageIngestor; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppSharedWebhookRouter; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppStatusUpdater; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppWebhookEventRecorder; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppWebhookProcessor; +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.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WhatsAppSharedWebhookControllerTest { + private static final String APP_TOKEN = "shared-app-token"; + private static final String WABA_ID = "200000000000001"; + + @Mock private WhatsAppWebhookConfigRepository configRepository; + @Mock private WhatsAppMetaAppRepository metaAppRepository; + @Mock private SmsLogRepository smsLogRepository; + @Mock private WhatsAppInboxMessageRepository inboxMessageRepository; + @Mock private WhatsAppRelayDeliveryRepository relayDeliveryRepository; + @Mock private WhatsAppTemplateRepository templateRepository; + @Mock private WhatsAppMediaService mediaService; + @Mock private WhatsAppWebhookEventRepository eventRepository; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private WhatsAppWebhookController controller() { + WhatsAppWebhookProcessor processor = new WhatsAppWebhookProcessor(configRepository, relayDeliveryRepository, + new WhatsAppWebhookEventRecorder(eventRepository, objectMapper), + new WhatsAppStatusUpdater(smsLogRepository, templateRepository), + new WhatsAppInboundMessageIngestor(inboxMessageRepository, mediaService), objectMapper); + return new WhatsAppWebhookController(configRepository, metaAppRepository, processor, + new WhatsAppSharedWebhookRouter(configRepository), objectMapper); + } + + private WhatsAppMetaApp app(String appSecret) { + WhatsAppMetaApp app = new WhatsAppMetaApp(); + app.setId(UUID.randomUUID()); + app.setName("Main business app"); + app.setVerifyToken("shared-verify-token"); + app.setAppSecret(appSecret); + app.setCallbackToken(APP_TOKEN); + app.setActive(true); + when(metaAppRepository.findByCallbackTokenAndActiveTrue(APP_TOKEN)).thenReturn(Optional.of(app)); + return app; + } + + private WhatsAppWebhookConfig config(WhatsAppMetaApp app, String phoneNumberId, String callbackUrl) { + WhatsAppWebhookConfig config = new WhatsAppWebhookConfig(); + config.setId(UUID.randomUUID()); + config.setPhoneNumberId(phoneNumberId); + config.setBusinessAccountId(WABA_ID); + config.setCallbackUrl(callbackUrl); + config.setMetaApp(app); + config.setActive(true); + User owner = new User(); + owner.setId(UUID.randomUUID()); + config.setCreatedBy(owner); + return config; + } + + private static String messageChange(String phoneNumberId, String messageId) { + return "{\"field\":\"messages\",\"value\":{\"metadata\":{\"phone_number_id\":\"" + phoneNumberId + "\"}," + + "\"messages\":[{\"id\":\"" + messageId + "\",\"from\":\"255700000000\",\"type\":\"text\"," + + "\"timestamp\":\"1700000000\",\"text\":{\"body\":\"hi\"}}]}}"; + } + + private static String payload(String... changes) { + return "{\"object\":\"whatsapp_business_account\",\"entry\":[{\"id\":\"" + WABA_ID + "\",\"changes\":[" + + String.join(",", changes) + "]}]}"; + } + + @Test + void verifySharedCallback_shouldEchoChallengeAndMarkVerified_whenTokenMatches() { + WhatsAppMetaApp app = app(null); + + ResponseEntity response = controller().verifySharedCallback(APP_TOKEN, "subscribe", "shared-verify-token", "42"); + + assertEquals("42", response.getBody()); + assertNotNull(app.getLastVerifiedAt()); + verify(metaAppRepository).save(app); + } + + @Test + void verifySharedCallback_shouldReturnForbidden_whenTokenDiffers() { + app(null); + + ResponseEntity response = controller().verifySharedCallback(APP_TOKEN, "subscribe", "wrong", "42"); + + assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode()); + verify(metaAppRepository, never()).save(any()); + } + + @Test + void receiveSharedCallback_shouldRouteEachChangeToItsPhoneNumberConfig() { + WhatsAppMetaApp app = app(null); + WhatsAppWebhookConfig first = config(app, "111", null); + WhatsAppWebhookConfig second = config(app, "222", null); + when(configRepository.findAllActiveByMetaAppId(app.getId())).thenReturn(List.of(first, second)); + + ResponseEntity response = controller().receiveSharedCallback(APP_TOKEN, + payload(messageChange("111", "wamid.A"), messageChange("222", "wamid.B")), null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + ArgumentCaptor saved = ArgumentCaptor.forClass(WhatsAppInboxMessage.class); + verify(inboxMessageRepository, times(2)).save(saved.capture()); + assertEquals(first, saved.getAllValues().get(0).getConfig()); + assertEquals(second, saved.getAllValues().get(1).getConfig()); + assertNotNull(app.getLastEventAt()); + } + + @Test + void receiveSharedCallback_shouldLearnDisplayPhoneNumber_fromMetadata() { + WhatsAppMetaApp app = app(null); + WhatsAppWebhookConfig first = config(app, "111", null); + when(configRepository.findAllActiveByMetaAppId(app.getId())).thenReturn(List.of(first)); + String change = "{\"field\":\"messages\",\"value\":{\"metadata\":{\"phone_number_id\":\"111\"," + + "\"display_phone_number\":\"255700000001\"},\"statuses\":[]}}"; + + controller().receiveSharedCallback(APP_TOKEN, payload(change), null); + + assertEquals("255700000001", first.getDisplayPhoneNumber()); + } + + @Test + void receiveSharedCallback_shouldRelayOnlyTheConfigsOwnChanges() throws Exception { + WhatsAppMetaApp app = app(null); + WhatsAppWebhookConfig first = config(app, "111", "https://one.example.com/hook"); + WhatsAppWebhookConfig second = config(app, "222", null); + when(configRepository.findAllActiveByMetaAppId(app.getId())).thenReturn(List.of(first, second)); + + controller().receiveSharedCallback(APP_TOKEN, payload(messageChange("111", "wamid.A"), messageChange("222", "wamid.B")), null); + + ArgumentCaptor relayed = ArgumentCaptor.forClass(WhatsAppRelayDelivery.class); + verify(relayDeliveryRepository).save(relayed.capture()); + String relayedPayload = relayed.getValue().getPayload(); + assertTrue(relayedPayload.contains("wamid.A")); + assertTrue(!relayedPayload.contains("wamid.B")); + assertEquals(WABA_ID, objectMapper.readTree(relayedPayload).at("/entry/0/id").asText()); + } + + @Test + void receiveSharedCallback_shouldIgnoreChanges_forPhoneNumbersNotLinkedToTheApp() { + WhatsAppMetaApp app = app(null); + when(configRepository.findAllActiveByMetaAppId(app.getId())).thenReturn(List.of(config(app, "111", null))); + + ResponseEntity response = controller().receiveSharedCallback(APP_TOKEN, payload(messageChange("999", "wamid.X")), null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(inboxMessageRepository, never()).save(any()); + } + + @Test + void receiveSharedCallback_shouldRouteWabaLevelEvents_byBusinessAccountId() { + WhatsAppMetaApp app = app(null); + WhatsAppWebhookConfig first = config(app, "111", null); + WhatsAppWebhookConfig other = config(app, "333", null); + other.setBusinessAccountId("another-waba"); + when(configRepository.findAllActiveByMetaAppId(app.getId())).thenReturn(List.of(first, other)); + String templateChange = "{\"field\":\"message_template_status_update\",\"value\":{\"event\":\"APPROVED\"," + + "\"message_template_name\":\"welcome\",\"message_template_language\":\"en\"}}"; + + controller().receiveSharedCallback(APP_TOKEN, payload(templateChange), null); + + verify(templateRepository).findFirstByNameAndLanguageAndCreatedBy("welcome", "en", first.getCreatedBy()); + verify(templateRepository, never()).findFirstByNameAndLanguageAndCreatedBy("welcome", "en", other.getCreatedBy()); + } + + @Test + void receiveSharedCallback_shouldRejectInvalidSignature_usingTheAppSecret() { + app("app-secret"); + + ResponseEntity response = controller().receiveSharedCallback(APP_TOKEN, payload(messageChange("111", "wamid.A")), "sha256=bogus"); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + verify(configRepository, never()).findAllActiveByMetaAppId(any()); + } + + @Test + void receiveSharedCallback_shouldAcceptValidSignature_usingTheAppSecret() throws Exception { + WhatsAppMetaApp app = app("app-secret"); + when(configRepository.findAllActiveByMetaAppId(app.getId())).thenReturn(List.of(config(app, "111", null))); + String raw = payload(messageChange("111", "wamid.A")); + + ResponseEntity response = controller().receiveSharedCallback(APP_TOKEN, raw, "sha256=" + HmacUtil.sha256Hex(raw, "app-secret")); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(inboxMessageRepository).save(any()); + } + + @Test + void receiveGeneratedCallback_shouldValidateWithLinkedAppSecret_whenConfigHasNoOwnSecret() { + WhatsAppMetaApp app = new WhatsAppMetaApp(); + app.setAppSecret("app-secret"); + WhatsAppWebhookConfig config = config(app, "111", null); + when(configRepository.findByCallbackTokenAndActiveTrue("number-token")).thenReturn(Optional.of(config)); + + ResponseEntity response = controller().receiveGeneratedCallback("number-token", payload(messageChange("111", "wamid.A")), "sha256=bogus"); + + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + } + + @Test + void receiveSharedCallback_shouldReturnOk_whenAppTokenUnknown() { + ResponseEntity response = controller().receiveSharedCallback("unknown", payload(), null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(configRepository, never()).findAllActiveByMetaAppId(any()); + } +} 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 1055dca..43ab6a8 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 @@ -6,12 +6,20 @@ import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppInboxMessage; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppRelayDelivery; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookEvent; 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.core.repositories.WhatsAppWebhookEventRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppMetaAppRepository; import com.flexcodelabs.flextuma.modules.whatsapp.services.WhatsAppMediaService; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppInboundMessageIngestor; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppSharedWebhookRouter; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppStatusUpdater; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppWebhookEventRecorder; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppWebhookProcessor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -21,6 +29,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -30,6 +39,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -54,6 +64,12 @@ class WhatsAppWebhookControllerTest { @Mock private WhatsAppMediaService mediaService; + @Mock + private WhatsAppWebhookEventRepository eventRepository; + + @Mock + private WhatsAppMetaAppRepository metaAppRepository; + private final ObjectMapper objectMapper = new ObjectMapper(); @BeforeEach @@ -64,7 +80,12 @@ void setUpDefaults() { } private WhatsAppWebhookController controller() { - return new WhatsAppWebhookController(configRepository, smsLogRepository, inboxMessageRepository, relayDeliveryRepository, templateRepository, mediaService, objectMapper); + WhatsAppWebhookProcessor processor = new WhatsAppWebhookProcessor(configRepository, relayDeliveryRepository, + new WhatsAppWebhookEventRecorder(eventRepository, objectMapper), + new WhatsAppStatusUpdater(smsLogRepository, templateRepository), + new WhatsAppInboundMessageIngestor(inboxMessageRepository, mediaService), objectMapper); + return new WhatsAppWebhookController(configRepository, metaAppRepository, processor, + new WhatsAppSharedWebhookRouter(configRepository), objectMapper); } private WhatsAppWebhookConfig activeConfig() { @@ -390,4 +411,83 @@ void receive_shouldNotSetError_whenStatusIsNotFailed() { verify(smsLogRepository).save(captor.capture()); assertNull(captor.getValue().getError()); } + + @Test + void receiveGeneratedCallback_shouldRecordEvent_forTemplateStatusUpdate() { + WhatsAppWebhookConfig config = activeConfig(); + config.setCallbackUrl(null); + when(configRepository.findByCallbackTokenAndActiveTrue("callback-token")).thenReturn(Optional.of(config)); + + String payload = "{\"entry\":[{\"changes\":[{\"field\":\"message_template_status_update\",\"value\":{" + + "\"event\":\"REJECTED\",\"message_template_id\":\"123\",\"reason\":\"INVALID_FORMAT\"," + + "\"message_template_name\":\"farm_alert\",\"message_template_language\":\"en\"}}]}]}"; + + controller().receiveGeneratedCallback("callback-token", payload, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WhatsAppWebhookEvent.class); + verify(eventRepository).save(captor.capture()); + WhatsAppWebhookEvent event = captor.getValue(); + assertEquals(config, event.getConfig()); + assertEquals(config.getCreatedBy(), event.getCreatedBy()); + assertEquals("message_template_status_update", event.getField()); + assertEquals("template.REJECTED", event.getEventType()); + assertEquals("farm_alert (en) REJECTED: INVALID_FORMAT", event.getSummary()); + assertNotNull(event.getPayload()); + assertNotNull(event.getReceivedAt()); + } + + @Test + void receive_shouldRecordOneEventPerChange_withMaskedNumbers() { + WhatsAppWebhookConfig config = activeConfig(); + when(configRepository.findByPhoneNumberIdAndActiveTrue("104725069208652")).thenReturn(Optional.of(config)); + + String payload = "{\"entry\":[{\"changes\":[" + + "{\"field\":\"messages\",\"value\":{\"metadata\":{\"phone_number_id\":\"104725069208652\"}," + + "\"statuses\":[{\"id\":\"wamid.X\",\"status\":\"delivered\",\"recipient_id\":\"255712345678\"}]}}," + + "{\"field\":\"account_alerts\",\"value\":{\"entity_type\":\"WABA\"}}" + + "]}]}"; + + controller().receive(payload, null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WhatsAppWebhookEvent.class); + verify(eventRepository, times(2)).save(captor.capture()); + List events = captor.getAllValues(); + assertEquals("status.delivered", events.get(0).getEventType()); + assertEquals("Message to •••• 5678 delivered", events.get(0).getSummary()); + assertEquals("account_alerts", events.get(1).getEventType()); + } + + @Test + void receive_shouldStillProcessDelivery_whenRecordingEventFails() { + WhatsAppWebhookConfig config = activeConfig(); + when(configRepository.findByPhoneNumberIdAndActiveTrue("104725069208652")).thenReturn(Optional.of(config)); + when(eventRepository.save(any())).thenThrow(new RuntimeException("db down")); + + SmsLog log = new SmsLog(); + log.setProviderMessageId("wamid.SENT"); + when(smsLogRepository.findByProviderMessageId("wamid.SENT")).thenReturn(Optional.of(log)); + + String payload = "{\"entry\":[{\"changes\":[{\"field\":\"messages\",\"value\":{" + + "\"metadata\":{\"phone_number_id\":\"104725069208652\"}," + + "\"statuses\":[{\"id\":\"wamid.SENT\",\"status\":\"sent\"}]}}]}]}"; + + ResponseEntity response = controller().receive(payload, null); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + verify(smsLogRepository).save(log); + } + + @Test + void receive_shouldNotRecordEvent_whenSignatureIsInvalid() { + WhatsAppWebhookConfig config = activeConfig(); + config.setAppSecret("app-secret"); + when(configRepository.findByPhoneNumberIdAndActiveTrue("104725069208652")).thenReturn(Optional.of(config)); + + String payload = "{\"entry\":[{\"changes\":[{\"field\":\"messages\",\"value\":{" + + "\"metadata\":{\"phone_number_id\":\"104725069208652\"}}}]}]}"; + + controller().receive(payload, "sha256=bogus"); + + verify(eventRepository, never()).save(any()); + } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionPolicyTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionPolicyTest.java new file mode 100644 index 0000000..3d901ef --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionPolicyTest.java @@ -0,0 +1,45 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class WhatsAppEventRetentionPolicyTest { + + private final WhatsAppEventRetentionPolicy policy = new WhatsAppEventRetentionPolicy(30, 90); + + @Test + void effectiveDays_shouldUseDefault_whenNoOverrides() { + assertEquals(30, policy.effectiveDays(null, null)); + assertEquals(30, policy.effectiveDays(0, 0)); + } + + @Test + void effectiveDays_shouldPreferWebhookThenAccount() { + assertEquals(7, policy.effectiveDays(7, 60)); + assertEquals(60, policy.effectiveDays(null, 60)); + assertEquals(60, policy.effectiveDays(0, 60)); + } + + @Test + void effectiveDays_shouldCapEveryLevelAtMaximum() { + assertEquals(90, policy.effectiveDays(400, null)); + assertEquals(90, policy.effectiveDays(null, 400)); + } + + @Test + void constructor_shouldCapDefaultAtMaximum() { + assertEquals(10, new WhatsAppEventRetentionPolicy(30, 10).defaultDays()); + } + + @Test + void validateOverride_shouldAllowZeroToResetAndRejectOutOfRange() { + assertDoesNotThrow(() -> policy.validateOverride(null)); + assertDoesNotThrow(() -> policy.validateOverride(0)); + assertDoesNotThrow(() -> policy.validateOverride(90)); + assertThrows(IllegalArgumentException.class, () -> policy.validateOverride(-1)); + assertThrows(IllegalArgumentException.class, () -> policy.validateOverride(91)); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionSchedulerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionSchedulerTest.java new file mode 100644 index 0000000..e3ec06a --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppEventRetentionSchedulerTest.java @@ -0,0 +1,81 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookEventRepository; +import com.flexcodelabs.flextuma.modules.retention.services.DataRetentionPolicyService; +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 java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WhatsAppEventRetentionSchedulerTest { + + @Mock + private WhatsAppWebhookEventRepository eventRepository; + + @Mock + private WhatsAppWebhookConfigRepository configRepository; + + @Mock + private DataRetentionPolicyService accountPolicies; + + @Test + void purgeExpiredEvents_shouldApplyWebhookThenAccountThenDefault_cappedAtMaximum() { + User accountWithPolicy = user(); + User accountWithout = user(); + WhatsAppWebhookConfig webhookOverride = config(accountWithPolicy, 7); + WhatsAppWebhookConfig inheritsAccount = config(accountWithPolicy, null); + WhatsAppWebhookConfig inheritsDefault = config(accountWithout, 0); + WhatsAppWebhookConfig overCap = config(accountWithout, 400); + when(configRepository.findAll()).thenReturn(List.of(webhookOverride, inheritsAccount, inheritsDefault, overCap)); + when(accountPolicies.whatsAppEventDaysFor(accountWithPolicy)).thenReturn(60); + when(accountPolicies.whatsAppEventDaysFor(accountWithout)).thenReturn(null); + + LocalDateTime now = LocalDateTime.now(); + new WhatsAppEventRetentionScheduler(eventRepository, configRepository, + new WhatsAppEventRetentionPolicy(30, 90), accountPolicies).purgeExpiredEvents(); + + assertCutoff(webhookOverride, now.minusDays(7)); + assertCutoff(inheritsAccount, now.minusDays(60)); + assertCutoff(inheritsDefault, now.minusDays(30)); + assertCutoff(overCap, now.minusDays(90)); + // Account policy is looked up once per owner, not once per config. + verify(accountPolicies, times(1)).whatsAppEventDaysFor(accountWithPolicy); + } + + private void assertCutoff(WhatsAppWebhookConfig config, LocalDateTime expected) { + ArgumentCaptor cutoff = ArgumentCaptor.forClass(LocalDateTime.class); + verify(eventRepository).deleteByConfigIdAndReceivedAtBefore(eq(config.getId()), cutoff.capture()); + assertTrue(Duration.between(expected, cutoff.getValue()).abs().toSeconds() < 5, + "expected ~" + expected + " but was " + cutoff.getValue()); + } + + private User user() { + User user = new User(); + user.setId(UUID.randomUUID()); + return user; + } + + private WhatsAppWebhookConfig config(User owner, Integer retentionDays) { + WhatsAppWebhookConfig config = new WhatsAppWebhookConfig(); + config.setId(UUID.randomUUID()); + config.setCreatedBy(owner); + config.setEventRetentionDays(retentionDays); + return config; + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaServiceTest.java index 586a2cd..6d7c61f 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMediaServiceTest.java @@ -71,7 +71,7 @@ void setUp() { @Test void download_shouldFetchMetadataThenBytesAndWriteToDisk_viaOwnerFallback() { - when(smsConnectorRepository.findByCreatedByAndProviderAndActiveTrue(owner, "WHATSAPP")) + when(smsConnectorRepository.findPreferredActiveConnector(owner, "WHATSAPP")) .thenReturn(Optional.of(connector)); when(restTemplate.exchange(eq("https://graph.facebook.com/v21.0/media-123"), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) @@ -96,7 +96,7 @@ void download_shouldScopeByOrganisation_whenOwnerBelongsToOne() { Organisation organisation = new Organisation(); organisation.setId(UUID.randomUUID()); owner.setOrganisation(organisation); - when(smsConnectorRepository.findByCreatedByAndProviderAndActiveTrue(owner, "WHATSAPP")) + when(smsConnectorRepository.findPreferredActiveConnector(owner, "WHATSAPP")) .thenReturn(Optional.of(connector)); when(restTemplate.exchange(eq("https://graph.facebook.com/v21.0/media-123"), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) @@ -126,12 +126,12 @@ void download_shouldUseConfigsLinkedConnector_withoutConsultingOwnerLookup() { Optional stored = service.download(config, "media-123"); assertTrue(stored.isPresent()); - verify(smsConnectorRepository, never()).findByCreatedByAndProviderAndActiveTrue(any(), any()); + verify(smsConnectorRepository, never()).findPreferredActiveConnector(any(), any()); } @Test void download_shouldReturnEmpty_whenOwnerHasNoActiveWhatsAppConnector() { - when(smsConnectorRepository.findByCreatedByAndProviderAndActiveTrue(owner, "WHATSAPP")) + when(smsConnectorRepository.findPreferredActiveConnector(owner, "WHATSAPP")) .thenReturn(Optional.empty()); Optional stored = service.download(config, "media-123"); @@ -141,7 +141,7 @@ void download_shouldReturnEmpty_whenOwnerHasNoActiveWhatsAppConnector() { @Test void download_shouldReturnEmpty_whenMetadataHasNoUrl() { - when(smsConnectorRepository.findByCreatedByAndProviderAndActiveTrue(owner, "WHATSAPP")) + when(smsConnectorRepository.findPreferredActiveConnector(owner, "WHATSAPP")) .thenReturn(Optional.of(connector)); when(restTemplate.exchange(eq("https://graph.facebook.com/v21.0/media-123"), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) @@ -154,7 +154,7 @@ void download_shouldReturnEmpty_whenMetadataHasNoUrl() { @Test void download_shouldReturnEmpty_whenHttpCallThrows() { - when(smsConnectorRepository.findByCreatedByAndProviderAndActiveTrue(owner, "WHATSAPP")) + when(smsConnectorRepository.findPreferredActiveConnector(owner, "WHATSAPP")) .thenReturn(Optional.of(connector)); when(restTemplate.exchange(any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) .thenThrow(new RuntimeException("network error")); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMetaAppServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMetaAppServiceTest.java new file mode 100644 index 0000000..9744393 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppMetaAppServiceTest.java @@ -0,0 +1,87 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services; + +import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppMetaApp; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppMetaAppRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MockitoExtension.class) +class WhatsAppMetaAppServiceTest { + + @Mock + private WhatsAppMetaAppRepository repository; + + @Mock + private WhatsAppWebhookConfigRepository configRepository; + + @InjectMocks + private WhatsAppMetaAppService service; + + @Test + void onPreSave_shouldProvisionSharedCallbackUrlAndVerifyToken() { + ReflectionTestUtils.setField(service, "publicBaseUrl", "https://api.example.com/"); + WhatsAppMetaApp app = new WhatsAppMetaApp(); + + service.onPreSave(app); + + assertNotNull(app.getVerifyToken()); + assertFalse(app.getVerifyToken().isBlank()); + assertEquals("https://api.example.com/api/webhooks/whatsapp/apps/" + app.getCallbackToken(), app.getMetaCallbackUrl()); + } + + @Test + void onPreSave_shouldKeepCallerSuppliedVerifyToken() { + ReflectionTestUtils.setField(service, "publicBaseUrl", "https://api.example.com"); + WhatsAppMetaApp app = new WhatsAppMetaApp(); + app.setVerifyToken("my-token"); + + service.onPreSave(app); + + assertEquals("my-token", app.getVerifyToken()); + } + + @Test + void onPreSave_shouldFail_whenPublicBaseUrlMissing() { + ReflectionTestUtils.setField(service, "publicBaseUrl", ""); + WhatsAppMetaApp app = new WhatsAppMetaApp(); + + assertThrows(IllegalStateException.class, () -> service.onPreSave(app)); + } + + @Test + void onPreUpdate_shouldKeepFlextumaOwnedValuesAndMaskedSecrets() { + WhatsAppMetaApp old = new WhatsAppMetaApp(); + old.setCallbackToken("token"); + old.setMetaCallbackUrl("https://api.example.com/api/webhooks/whatsapp/apps/token"); + old.setVerifyToken("old-verify"); + old.setAppSecret("old-secret"); + old.setLastVerifiedAt(LocalDateTime.now()); + WhatsAppMetaApp incoming = new WhatsAppMetaApp(); + incoming.setName("Renamed"); + incoming.setCallbackToken("forged"); + incoming.setMetaCallbackUrl("https://evil.example.com"); + incoming.setVerifyToken("ol****fy"); + incoming.setAppSecret("new-secret"); + + WhatsAppMetaApp merged = service.onPreUpdate(incoming, old); + + assertEquals("token", merged.getCallbackToken()); + assertTrue(merged.getMetaCallbackUrl().endsWith("/apps/token")); + assertEquals("old-verify", merged.getVerifyToken()); + assertEquals("new-secret", merged.getAppSecret()); + assertEquals("Renamed", merged.getName()); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppRelayDispatchWorkerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppRelayDispatchWorkerTest.java index 1a14174..25d5bba 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppRelayDispatchWorkerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppRelayDispatchWorkerTest.java @@ -1,22 +1,34 @@ package com.flexcodelabs.flextuma.modules.whatsapp.services; -import com.fasterxml.jackson.databind.ObjectMapper; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppRelayDelivery; import com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppWebhookConfig; import com.flexcodelabs.flextuma.core.enums.WhatsAppRelayStatus; import com.flexcodelabs.flextuma.core.repositories.WhatsAppRelayDeliveryRepository; +import com.flexcodelabs.flextuma.modules.notification.services.MessageRetryPolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; +import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +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.never; @@ -26,19 +38,20 @@ @ExtendWith(MockitoExtension.class) class WhatsAppRelayDispatchWorkerTest { + // 10s initial delay doubling each time, capped at 15s, retried for 12h. + private static final MessageRetryPolicy POLICY = new MessageRetryPolicy(10_000, 2.0, 15_000, 43_200_000); + @Mock private WhatsAppRelayDeliveryRepository deliveryRepository; @Mock private RestTemplate restTemplate; - private final ObjectMapper objectMapper = new ObjectMapper(); - private WhatsAppRelayDispatchWorker worker; @BeforeEach void setUp() { - worker = new WhatsAppRelayDispatchWorker(deliveryRepository, restTemplate, objectMapper); + worker = new WhatsAppRelayDispatchWorker(deliveryRepository, restTemplate, POLICY); } private WhatsAppRelayDelivery pendingDelivery() { @@ -50,67 +63,138 @@ private WhatsAppRelayDelivery pendingDelivery() { delivery.setConfig(config); delivery.setPayload("{\"hello\":\"world\"}"); delivery.setRetries(0); + delivery.setCreated(LocalDateTime.now()); return delivery; } + private void stubDue(WhatsAppRelayDelivery delivery) { + when(deliveryRepository.findDueDeliveries(eq(WhatsAppRelayStatus.PENDING), any(LocalDateTime.class), + any(Pageable.class))).thenReturn(List.of(delivery)); + when(deliveryRepository.claimPendingDelivery(any(), eq(WhatsAppRelayStatus.PENDING), eq(WhatsAppRelayStatus.PROCESSING))) + .thenReturn(1); + when(deliveryRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + } + + private void stubPostThrows(RuntimeException e) { + when(restTemplate.postForEntity(any(String.class), any(), eq(Void.class))).thenThrow(e); + } + + private static void assertRetryInSeconds(WhatsAppRelayDelivery delivery, long seconds, LocalDateTime before) { + LocalDateTime nextRetryAt = delivery.getNextRetryAt(); + assertNotNull(nextRetryAt); + assertFalse(nextRetryAt.isBefore(before.plusSeconds(seconds))); + assertTrue(nextRetryAt.isBefore(LocalDateTime.now().plusSeconds(seconds + 1))); + } + @Test void dispatch_shouldMarkSucceeded_whenPostSucceeds() { WhatsAppRelayDelivery delivery = pendingDelivery(); - when(deliveryRepository.findTop50ByStatusOrderByCreatedAsc(WhatsAppRelayStatus.PENDING)) - .thenReturn(List.of(delivery)); - when(deliveryRepository.claimPendingDelivery(any(), eq(WhatsAppRelayStatus.PENDING), eq(WhatsAppRelayStatus.PROCESSING))) - .thenReturn(1); + delivery.setNextRetryAt(LocalDateTime.now().minusSeconds(1)); + stubDue(delivery); when(restTemplate.postForEntity(any(String.class), any(), eq(Void.class))) .thenReturn(ResponseEntity.ok().build()); - when(deliveryRepository.save(any())).thenAnswer(i -> i.getArgument(0)); worker.dispatch(); assertEquals(WhatsAppRelayStatus.SUCCEEDED, delivery.getStatus()); assertEquals(0, delivery.getRetries()); + assertNull(delivery.getNextRetryAt()); + assertNotNull(delivery.getLastAttemptAt()); } @Test - void dispatch_shouldRetry_whenPostFailsAndRetriesBelowMax() { + void dispatch_shouldScheduleRetryWithInitialBackoff_whenTransportErrorOnFirstAttempt() { WhatsAppRelayDelivery delivery = pendingDelivery(); - when(deliveryRepository.findTop50ByStatusOrderByCreatedAsc(WhatsAppRelayStatus.PENDING)) - .thenReturn(List.of(delivery)); - when(deliveryRepository.claimPendingDelivery(any(), eq(WhatsAppRelayStatus.PENDING), eq(WhatsAppRelayStatus.PROCESSING))) - .thenReturn(1); - when(restTemplate.postForEntity(any(String.class), any(), eq(Void.class))) - .thenThrow(new RuntimeException("connection refused")); - when(deliveryRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + stubDue(delivery); + stubPostThrows(new ResourceAccessException("connection refused")); + LocalDateTime before = LocalDateTime.now(); worker.dispatch(); assertEquals(WhatsAppRelayStatus.PENDING, delivery.getStatus()); assertEquals(1, delivery.getRetries()); assertEquals("connection refused", delivery.getLastError()); + assertRetryInSeconds(delivery, 10, before); } @Test - void dispatch_shouldMarkFailed_whenMaxRetriesReached() { + void dispatch_shouldCapBackoff_whenServerErrorOnLaterAttempt() { WhatsAppRelayDelivery delivery = pendingDelivery(); - delivery.setRetries(2); - when(deliveryRepository.findTop50ByStatusOrderByCreatedAsc(WhatsAppRelayStatus.PENDING)) - .thenReturn(List.of(delivery)); - when(deliveryRepository.claimPendingDelivery(any(), eq(WhatsAppRelayStatus.PENDING), eq(WhatsAppRelayStatus.PROCESSING))) - .thenReturn(1); - when(restTemplate.postForEntity(any(String.class), any(), eq(Void.class))) - .thenThrow(new RuntimeException("timeout")); - when(deliveryRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + delivery.setRetries(1); + stubDue(delivery); + stubPostThrows(new HttpServerErrorException(HttpStatus.BAD_GATEWAY)); + LocalDateTime before = LocalDateTime.now(); + + worker.dispatch(); + + // attempt 2 would wait 20s, but max-interval caps it at 15s + assertEquals(WhatsAppRelayStatus.PENDING, delivery.getStatus()); + assertEquals(2, delivery.getRetries()); + assertRetryInSeconds(delivery, 15, before); + } + + @Test + void dispatch_shouldKeepRetrying_afterManyAttemptsWithinWindow() { + WhatsAppRelayDelivery delivery = pendingDelivery(); + delivery.setRetries(20); + delivery.setCreated(LocalDateTime.now().minusHours(11)); + stubDue(delivery); + stubPostThrows(new ResourceAccessException("timeout")); + + worker.dispatch(); + + assertEquals(WhatsAppRelayStatus.PENDING, delivery.getStatus()); + assertEquals(21, delivery.getRetries()); + assertNotNull(delivery.getNextRetryAt()); + } + + @Test + void dispatch_shouldMarkFailed_whenRetryWindowExhausted() { + WhatsAppRelayDelivery delivery = pendingDelivery(); + delivery.setRetries(20); + delivery.setCreated(LocalDateTime.now().minusHours(12).minusMinutes(1)); + delivery.setNextRetryAt(LocalDateTime.now().minusSeconds(1)); + stubDue(delivery); + stubPostThrows(new ResourceAccessException("timeout")); worker.dispatch(); assertEquals(WhatsAppRelayStatus.FAILED, delivery.getStatus()); - assertEquals(3, delivery.getRetries()); + assertEquals(21, delivery.getRetries()); + assertNull(delivery.getNextRetryAt()); + } + + @ParameterizedTest + @ValueSource(ints = {400, 401, 403, 404, 408, 410, 422, 429}) + void dispatch_shouldRetry_whenCallbackReturns4xx(int status) { + WhatsAppRelayDelivery delivery = pendingDelivery(); + stubDue(delivery); + stubPostThrows(HttpClientErrorException.create(HttpStatus.valueOf(status), "rejected", null, null, null)); + + worker.dispatch(); + + assertEquals(WhatsAppRelayStatus.PENDING, delivery.getStatus()); + assertEquals(1, delivery.getRetries()); + assertNotNull(delivery.getNextRetryAt()); + } + + @Test + void dispatch_shouldRetry_whenErrorIsUnexpected() { + WhatsAppRelayDelivery delivery = pendingDelivery(); + stubDue(delivery); + stubPostThrows(new IllegalStateException("bad payload")); + + worker.dispatch(); + + assertEquals(WhatsAppRelayStatus.PENDING, delivery.getStatus()); + assertEquals("bad payload", delivery.getLastError()); } @Test void dispatch_shouldSkip_whenClaimLostToAnotherWorker() { WhatsAppRelayDelivery delivery = pendingDelivery(); - when(deliveryRepository.findTop50ByStatusOrderByCreatedAsc(WhatsAppRelayStatus.PENDING)) - .thenReturn(List.of(delivery)); + when(deliveryRepository.findDueDeliveries(eq(WhatsAppRelayStatus.PENDING), any(LocalDateTime.class), + any(Pageable.class))).thenReturn(List.of(delivery)); when(deliveryRepository.claimPendingDelivery(any(), eq(WhatsAppRelayStatus.PENDING), eq(WhatsAppRelayStatus.PROCESSING))) .thenReturn(0); @@ -121,9 +205,9 @@ void dispatch_shouldSkip_whenClaimLostToAnotherWorker() { } @Test - void dispatch_shouldDoNothing_whenNoPendingDeliveries() { - when(deliveryRepository.findTop50ByStatusOrderByCreatedAsc(WhatsAppRelayStatus.PENDING)) - .thenReturn(Collections.emptyList()); + void dispatch_shouldDoNothing_whenNoDueDeliveries() { + when(deliveryRepository.findDueDeliveries(eq(WhatsAppRelayStatus.PENDING), any(LocalDateTime.class), + any(Pageable.class))).thenReturn(Collections.emptyList()); worker.dispatch(); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigServiceTest.java index 28ba8de..6698338 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppWebhookConfigServiceTest.java @@ -9,6 +9,7 @@ import com.flexcodelabs.flextuma.core.enums.WhatsAppRelayStatus; import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppMetaAppRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppWebhookOverviewDTO; import org.junit.jupiter.api.BeforeEach; @@ -16,6 +17,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -53,6 +55,12 @@ class WhatsAppWebhookConfigServiceTest { @Mock private CurrentUserResolver currentUserResolver; + @Mock + private WhatsAppMetaAppRepository metaAppRepository; + + @Spy + private WhatsAppEventRetentionPolicy eventRetentionPolicy = new WhatsAppEventRetentionPolicy(30, 90); + @InjectMocks private WhatsAppWebhookConfigService service; @@ -241,4 +249,22 @@ void onPreSave_shouldAllowNoConnector() { assertDoesNotThrow(() -> service.onPreSave(entity)); } + + @Test + void onPreSave_shouldAcceptEventRetentionWithinPlatformMaximum() { + WhatsAppWebhookConfig entity = new WhatsAppWebhookConfig(); + entity.setPhoneNumberId("104725069208652"); + entity.setEventRetentionDays(90); + + assertDoesNotThrow(() -> service.onPreSave(entity)); + } + + @Test + void onPreSave_shouldRejectEventRetentionAbovePlatformMaximum() { + WhatsAppWebhookConfig entity = new WhatsAppWebhookConfig(); + entity.setPhoneNumberId("104725069208652"); + entity.setEventRetentionDays(91); + + assertThrows(IllegalArgumentException.class, () -> service.onPreSave(entity)); + } } From 7c0eebab756b0060fbca91e79038bd48a18f7b78 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 21 Oct 2026 18:47:45 +0000 Subject: [PATCH 2/2] Release v0.0.84 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 962fbff..516c9d2 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.83' +version = '0.0.84' description = 'Flextuma App' java {