Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<HMAC-SHA256(raw-body)>` 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.
Expand Down
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.83'
version = '0.0.84'
description = 'Flextuma App'

java {
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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<String, Object> whatsappTemplate;

}
Original file line number Diff line number Diff line change
@@ -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); }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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); }
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Loading