From 5eed7ead4a390b9b52ceee5a4a108b8465647bd7 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 21 Oct 2026 22:27:20 +0300 Subject: [PATCH 1/3] release: Add multi tenant token reuse --- README.md | 6 +++- .../core/entities/sms/SmsConnector.java | 9 ++++++ .../whatsapp/WhatsAppWebhookConfig.java | 4 +++ .../WhatsAppInboxMessageRepository.java | 2 ++ .../WhatsAppRelayDeliveryRepository.java | 2 ++ .../sms/services/SmsConnectorService.java | 31 +++++++++++++++++++ .../WhatsAppWebhookConfigService.java | 13 ++++++++ .../WhatsAppWebhookConfigServiceTest.java | 25 +++++++++++++++ 8 files changed, 91 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7774c55..39ec5eb 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ Set `FLEXTUMA_PUBLIC_BASE_URL` to Flextuma's public HTTPS origin before creating #### 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. +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. To add another number that uses a token you already saved, create the connector with `"reuseKeyFrom": ""` and no `key`. Flextuma copies the key, secret and URL from that connector server-side; it must be yours and use the same provider. `POST /api/whatsappMeta/check` with `{ "connectorId", "tokenOnly": true, "businessAccountId" }` lists that account's numbers using the saved token. 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: @@ -271,6 +271,10 @@ The response includes a generated `metaCallbackUrl` (`/api/webhooks/whatsapp/app 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. +#### Checking credentials with Meta + +`POST /api/whatsappMeta/check` validates WhatsApp credentials against the Graph API before (or after) they are saved. It takes `{ "accessToken", "graphApiUrl", "businessAccountId" }` for a number not yet saved, or `{ "connectorId" }` to reuse a saved connector's token; that token never leaves the server. The response returns `ok` (or a readable `error` for expired tokens, missing permissions or unknown IDs), the WABA's `phoneNumbers` (display number, verified name, quality rating), the requested `phoneNumber`, and `subscribedApps` (the Meta apps receiving the WABA's webhooks). `POST /api/whatsappMeta/subscribe` calls `POST /{WABA_ID}/subscribed_apps` for the token's app and returns the refreshed check. Only `https://graph.facebook.com` is called, and IDs must be numeric. + ### 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/entities/sms/SmsConnector.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/sms/SmsConnector.java index 3e4150c..cb99e96 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 @@ -58,6 +58,15 @@ public class SmsConnector extends Owner { @Column(columnDefinition = "TEXT", name = "extrasettings", nullable = true) private String extraSettings; + /** + * Create-only: copy key/secret (and url, if blank) from this other connector of the same owner + * and provider, so one stored credential -- e.g. a Meta System User token shared by several + * WhatsApp numbers -- can be reused without ever being sent back to the browser. + */ + @Transient + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) + private java.util.UUID reuseKeyFrom; + @JsonProperty("key") public String getMaskedKey() { return MaskingUtil.mask(this.key); 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 9905901..9056ccb 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 @@ -47,6 +47,10 @@ public class WhatsAppWebhookConfig extends Owner { @Column(name = "display_phone_number") private String displayPhoneNumber; + /** Business name Meta shows customers for this number (verified_name), read from Meta at setup. */ + @Column(name = "verified_name") + private String verifiedName; + /** * 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 diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppInboxMessageRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppInboxMessageRepository.java index 70d9e8c..d7714ff 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppInboxMessageRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppInboxMessageRepository.java @@ -19,6 +19,8 @@ public interface WhatsAppInboxMessageRepository extends BaseRepository { boolean existsByProviderMessageId(String providerMessageId); + boolean existsByConfigId(UUID configId); + /** Candidates for {@code WhatsAppMediaBackfillWorker}: messages that carry a Meta media id * but never got a cached copy on disk, bounded to recent messages since Meta's CDN only * keeps media retrievable for a limited window. */ 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 48a9d8f..415564c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppRelayDeliveryRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppRelayDeliveryRepository.java @@ -16,6 +16,8 @@ @Repository public interface WhatsAppRelayDeliveryRepository extends BaseRepository, JpaSpecificationExecutor { + boolean existsByConfigId(UUID configId); + @Query("SELECT d FROM WhatsAppRelayDelivery d WHERE d.status = :status" + " AND (d.nextRetryAt IS NULL OR d.nextRetryAt <= :now) ORDER BY d.created ASC") diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java index 8537597..af2fc32 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorService.java @@ -1,12 +1,15 @@ package com.flexcodelabs.flextuma.modules.sms.services; +import java.util.Optional; import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Service; +import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; import com.flexcodelabs.flextuma.core.services.BaseService; @@ -17,6 +20,7 @@ public class SmsConnectorService extends BaseService { private final SmsConnectorRepository repository; + private final CurrentUserResolver userResolver; @Override protected JpaRepository getRepository() { @@ -70,9 +74,36 @@ protected String getTableName() { @Override protected void onPreSave(SmsConnector entity) { + reuseSavedKey(entity); validateProviderConfig(entity); } + private void reuseSavedKey(SmsConnector entity) { + if (entity.getReuseKeyFrom() == null) { + return; + } + SmsConnector source = repository.findById(entity.getReuseKeyFrom()) + .filter(found -> found.getProvider() != null && found.getProvider().equalsIgnoreCase(entity.getProvider())) + .filter(this::isOwnedByCurrentUser) + .orElseThrow(() -> new IllegalArgumentException("The saved credentials to reuse were not found")); + entity.setKey(source.getKey()); + if (isBlank(entity.getSecret())) { + entity.setSecret(source.getSecret()); + } + if (isBlank(entity.getUrl())) { + entity.setUrl(source.getUrl()); + } + } + + private boolean isOwnedByCurrentUser(SmsConnector connector) { + Optional user = userResolver.getCurrentUser(); + return user.isPresent() && connector.getCreatedBy() != null && connector.getCreatedBy().getId().equals(user.get().getId()); + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } + @Override protected SmsConnector onPreUpdate(SmsConnector newEntity, SmsConnector oldEntity) { SmsConnector merged = super.onPreUpdate(newEntity, oldEntity); 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 7c817ef..bc8343a 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 @@ -9,7 +9,9 @@ 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.WhatsAppInboxMessageRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppMetaAppRepository; +import com.flexcodelabs.flextuma.core.repositories.WhatsAppRelayDeliveryRepository; import com.flexcodelabs.flextuma.core.repositories.WhatsAppWebhookConfigRepository; import com.flexcodelabs.flextuma.core.services.BaseService; import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppActivityItemDTO; @@ -46,6 +48,8 @@ public class WhatsAppWebhookConfigService extends BaseService service.onPreSave(entity)); } + + @Test + void validateDelete_shouldRefuse_whenNumberHasMessageHistory() { + WhatsAppWebhookConfig config = new WhatsAppWebhookConfig(); + config.setId(UUID.randomUUID()); + when(inboxMessageRepository.existsByConfigId(config.getId())).thenReturn(true); + + assertThrows(IllegalStateException.class, () -> service.validateDelete(config)); + } + + @Test + void validateDelete_shouldAllow_whenNumberHasNoHistory() { + WhatsAppWebhookConfig config = new WhatsAppWebhookConfig(); + config.setId(UUID.randomUUID()); + + assertDoesNotThrow(() -> service.validateDelete(config)); + } } From b341a3dd2ffd402519091e3474707311aa8d14b7 Mon Sep 17 00:00:00 2001 From: Bennett Date: Wed, 21 Oct 2026 22:27:36 +0300 Subject: [PATCH 2/3] release: Add multi tenant token reuse --- .../controllers/WhatsAppMetaController.java | 31 +++ .../dtos/WhatsAppMetaCheckRequest.java | 22 ++ .../dtos/WhatsAppMetaCheckResult.java | 24 ++ .../dtos/WhatsAppMetaPhoneNumberDTO.java | 12 + .../services/meta/MetaGraphClient.java | 97 ++++++++ .../services/meta/MetaGraphException.java | 14 ++ .../services/meta/WhatsAppMetaService.java | 155 +++++++++++++ .../sms/services/SmsConnectorServiceTest.java | 91 ++++++++ .../meta/WhatsAppMetaServiceTest.java | 214 ++++++++++++++++++ 9 files changed, 660 insertions(+) create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppMetaController.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaCheckRequest.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaCheckResult.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaPhoneNumberDTO.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/MetaGraphClient.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/MetaGraphException.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaService.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorServiceTest.java create mode 100644 src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaServiceTest.java diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppMetaController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppMetaController.java new file mode 100644 index 0000000..3f9b19e --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppMetaController.java @@ -0,0 +1,31 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.controllers; + +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckRequest; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckResult; +import com.flexcodelabs.flextuma.modules.whatsapp.services.meta.WhatsAppMetaService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Live checks against Meta's Graph API for the WhatsApp setup screens (authenticated). */ +@RestController +@RequestMapping("/api/whatsappMeta") +@RequiredArgsConstructor +public class WhatsAppMetaController { + private final WhatsAppMetaService metaService; + + /** Validates credentials and returns the account's phone numbers and app subscriptions. */ + @PostMapping("/check") + public ResponseEntity check(@RequestBody WhatsAppMetaCheckRequest request) { + return ResponseEntity.ok(metaService.check(request)); + } + + /** Subscribes the token's Meta app to the WhatsApp Business Account's webhooks. */ + @PostMapping("/subscribe") + public ResponseEntity subscribe(@RequestBody WhatsAppMetaCheckRequest request) { + return ResponseEntity.ok(metaService.subscribe(request)); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaCheckRequest.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaCheckRequest.java new file mode 100644 index 0000000..e6142ce --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaCheckRequest.java @@ -0,0 +1,22 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.dtos; + +import java.util.UUID; + +/** + * Credentials to check against Meta. Either supply {@code accessToken} (+ {@code graphApiUrl}) for + * a number not yet saved, or {@code connectorId} to reuse a saved connector's token, which never + * leaves the server. With {@code tokenOnly}, only the connector's token is borrowed -- its own + * WABA and phone number are not used as defaults -- so a saved token can look up another number. + */ +public record WhatsAppMetaCheckRequest( + String graphApiUrl, + String accessToken, + String businessAccountId, + String phoneNumberId, + UUID connectorId, + Boolean tokenOnly) { + + public WhatsAppMetaCheckRequest(String graphApiUrl, String accessToken, String businessAccountId, String phoneNumberId, UUID connectorId) { + this(graphApiUrl, accessToken, businessAccountId, phoneNumberId, connectorId, null); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaCheckResult.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaCheckResult.java new file mode 100644 index 0000000..c1375fb --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaCheckResult.java @@ -0,0 +1,24 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.dtos; + +import java.util.List; + +/** + * Outcome of checking credentials against Meta. {@code ok} is false with {@code error} set when + * Meta rejected them; the HTTP status stays 200 so the UI can show the reason inline. + * + * @param phoneNumbers every number in the WhatsApp Business Account, when one was given + * @param phoneNumber the requested number, when a phone number ID was given + * @param subscribedApps names of Meta apps receiving this WABA's webhooks; null when not checked + */ +public record WhatsAppMetaCheckResult( + boolean ok, + String error, + String errorCode, + List phoneNumbers, + WhatsAppMetaPhoneNumberDTO phoneNumber, + List subscribedApps) { + + public static WhatsAppMetaCheckResult failure(String errorCode, String error) { + return new WhatsAppMetaCheckResult(false, error, errorCode, List.of(), null, null); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaPhoneNumberDTO.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaPhoneNumberDTO.java new file mode 100644 index 0000000..99241b3 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/dtos/WhatsAppMetaPhoneNumberDTO.java @@ -0,0 +1,12 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.dtos; + +/** A phone number as Meta reports it (GET /{phone-number-id}). */ +public record WhatsAppMetaPhoneNumberDTO( + String id, + String displayPhoneNumber, + String verifiedName, + String qualityRating, + String codeVerificationStatus, + String nameStatus, + String platformType) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/MetaGraphClient.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/MetaGraphClient.java new file mode 100644 index 0000000..7612978 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/MetaGraphClient.java @@ -0,0 +1,97 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.meta; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Map; + +/** + * Minimal Graph API client for checking WhatsApp credentials. Only https://graph.facebook.com is + * allowed: the base URL comes from user input, and the request carries their access token, so any + * other host would turn "test connection" into a way to probe arbitrary URLs. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MetaGraphClient { + static final String GRAPH_HOST = "graph.facebook.com"; + private static final TypeReference> MAP_TYPE = new TypeReference<>() { + }; + private static final Map FRIENDLY_ERRORS = Map.of( + 190, "The access token is invalid or has expired. Use a permanent System User token from Meta Business Settings.", + 100, "Meta couldn't find that ID, or this access token can't access it. Check the ID and that the token's System User is assigned to the WhatsApp account.", + 10, "This access token is missing a permission. It needs whatsapp_business_management and whatsapp_business_messaging.", + 200, "This access token is missing a permission. It needs whatsapp_business_management and whatsapp_business_messaging.", + 4, "Meta is rate limiting requests for this app. Try again in a minute.", + 80007, "Meta is rate limiting requests for this WhatsApp account. Try again in a minute."); + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public Map get(String baseUrl, String token, String path) { + return call(HttpMethod.GET, baseUrl, token, path); + } + + public Map post(String baseUrl, String token, String path) { + return call(HttpMethod.POST, baseUrl, token, path); + } + + public static String validatedBaseUrl(String baseUrl) { + URI uri = parse(baseUrl); + if (!"https".equalsIgnoreCase(uri.getScheme()) || !GRAPH_HOST.equalsIgnoreCase(uri.getHost())) { + throw new MetaGraphException("INVALID_URL", "The Graph API URL must be https://graph.facebook.com/."); + } + return baseUrl.replaceAll("/+$", ""); + } + + private static URI parse(String baseUrl) { + try { + return URI.create(baseUrl == null ? "" : baseUrl.trim()); + } catch (IllegalArgumentException e) { + throw new MetaGraphException("INVALID_URL", "The Graph API URL is not a valid URL."); + } + } + + private Map call(HttpMethod method, String baseUrl, String token, String path) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + try { + ResponseEntity response = restTemplate.exchange(validatedBaseUrl(baseUrl) + path, method, new HttpEntity<>(headers), Map.class); + return objectMapper.convertValue(response.getBody() == null ? Map.of() : response.getBody(), MAP_TYPE); + } catch (HttpStatusCodeException e) { + throw translate(e.getResponseBodyAsString()); + } catch (ResourceAccessException e) { + log.warn("Could not reach the Meta Graph API: {}", e.getMessage()); + throw new MetaGraphException("UNREACHABLE", "Could not reach Meta. Check your connection and try again."); + } + } + + MetaGraphException translate(String body) { + Map error = errorOf(body); + Object code = error.get("code"); + String fallback = error.get("message") instanceof String message ? message : "Meta rejected the request."; + Integer numeric = code instanceof Number number ? number.intValue() : null; + return new MetaGraphException(String.valueOf(code), FRIENDLY_ERRORS.getOrDefault(numeric, fallback)); + } + + private Map errorOf(String body) { + try { + Object error = objectMapper.readValue(body, MAP_TYPE).get("error"); + return error instanceof Map ? objectMapper.convertValue(error, MAP_TYPE) : Map.of(); + } catch (JsonProcessingException | IllegalArgumentException e) { + return Map.of(); + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/MetaGraphException.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/MetaGraphException.java new file mode 100644 index 0000000..b40c8b3 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/MetaGraphException.java @@ -0,0 +1,14 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.meta; + +import lombok.Getter; + +/** A Graph API call that failed, carrying a message fit to show the user. */ +@Getter +public class MetaGraphException extends RuntimeException { + private final String code; + + public MetaGraphException(String code, String message) { + super(message); + this.code = code; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaService.java new file mode 100644 index 0000000..bf4e708 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaService.java @@ -0,0 +1,155 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.meta; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckRequest; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckResult; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaPhoneNumberDTO; +import com.flexcodelabs.flextuma.modules.whatsapp.services.webhook.WhatsAppPayloads; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** Checks WhatsApp Cloud API credentials against Meta and reads phone numbers from it. */ +@Service +@RequiredArgsConstructor +public class WhatsAppMetaService { + static final String DEFAULT_GRAPH_API_URL = "https://graph.facebook.com/v21.0"; + private static final String PHONE_FIELDS = "id,display_phone_number,verified_name,quality_rating,code_verification_status,name_status,platform_type"; + private static final Pattern META_ID = Pattern.compile("^\\d{5,32}$"); + private static final String DATA = "data"; + + private final MetaGraphClient graph; + private final SmsConnectorRepository connectorRepository; + private final CurrentUserResolver currentUserResolver; + private final ObjectMapper objectMapper; + + record Credentials(String baseUrl, String token, String businessAccountId, String phoneNumberId) { + } + + public WhatsAppMetaCheckResult check(WhatsAppMetaCheckRequest request) { + try { + return check(resolve(request)); + } catch (MetaGraphException e) { + return WhatsAppMetaCheckResult.failure(e.getCode(), e.getMessage()); + } + } + + /** Subscribes the token's Meta app to the WABA's webhooks (POST /{waba}/subscribed_apps). */ + public WhatsAppMetaCheckResult subscribe(WhatsAppMetaCheckRequest request) { + try { + Credentials credentials = resolve(request); + if (credentials.businessAccountId() == null) { + throw new MetaGraphException("MISSING_WABA", "Enter the WhatsApp Business Account ID to subscribe it."); + } + graph.post(credentials.baseUrl(), credentials.token(), "/" + credentials.businessAccountId() + "/subscribed_apps"); + return check(credentials); + } catch (MetaGraphException e) { + return WhatsAppMetaCheckResult.failure(e.getCode(), e.getMessage()); + } + } + + private WhatsAppMetaCheckResult check(Credentials credentials) { + if (credentials.businessAccountId() == null && credentials.phoneNumberId() == null) { + throw new MetaGraphException("MISSING_ID", "Enter a WhatsApp Business Account ID or a phone number ID."); + } + List numbers = credentials.businessAccountId() == null ? List.of() : listNumbers(credentials); + WhatsAppMetaPhoneNumberDTO number = credentials.phoneNumberId() == null ? null : findNumber(credentials, numbers); + List apps = credentials.businessAccountId() == null ? null : subscribedApps(credentials); + return new WhatsAppMetaCheckResult(true, null, null, numbers, number, apps); + } + + private List listNumbers(Credentials credentials) { + Map body = graph.get(credentials.baseUrl(), credentials.token(), + "/" + credentials.businessAccountId() + "/phone_numbers?fields=" + PHONE_FIELDS); + return WhatsAppPayloads.mapList(body, DATA).stream().map(WhatsAppMetaService::toPhoneNumber).toList(); + } + + private WhatsAppMetaPhoneNumberDTO findNumber(Credentials credentials, List numbers) { + if (numbers.isEmpty()) { + return toPhoneNumber(graph.get(credentials.baseUrl(), credentials.token(), "/" + credentials.phoneNumberId() + "?fields=" + PHONE_FIELDS)); + } + return numbers.stream().filter(n -> Objects.equals(n.id(), credentials.phoneNumberId())).findFirst() + .orElseThrow(() -> new MetaGraphException("NOT_IN_WABA", "That phone number isn't in this WhatsApp Business Account.")); + } + + private List subscribedApps(Credentials credentials) { + Map body = graph.get(credentials.baseUrl(), credentials.token(), "/" + credentials.businessAccountId() + "/subscribed_apps"); + return WhatsAppPayloads.mapList(body, DATA).stream() + .map(app -> WhatsAppPayloads.nestedMap(app, "whatsapp_business_api_data")) + .map(app -> WhatsAppPayloads.textOr(app.getOrDefault("name", app.get("id")), "Unnamed app")) + .toList(); + } + + static WhatsAppMetaPhoneNumberDTO toPhoneNumber(Map raw) { + return new WhatsAppMetaPhoneNumberDTO(text(raw, "id"), text(raw, "display_phone_number"), text(raw, "verified_name"), + text(raw, "quality_rating"), text(raw, "code_verification_status"), text(raw, "name_status"), text(raw, "platform_type")); + } + + private static String text(Map raw, String key) { + return WhatsAppPayloads.textOr(raw.get(key), null); + } + + Credentials resolve(WhatsAppMetaCheckRequest request) { + if (request.connectorId() != null) { + return fromConnector(request); + } + String token = blankToNull(request.accessToken()); + if (token == null) { + throw new MetaGraphException("MISSING_TOKEN", "Enter the access token."); + } + String baseUrl = Optional.ofNullable(blankToNull(request.graphApiUrl())).orElse(DEFAULT_GRAPH_API_URL); + return new Credentials(MetaGraphClient.validatedBaseUrl(baseUrl), token, + metaId(request.businessAccountId(), "WhatsApp Business Account ID"), metaId(request.phoneNumberId(), "Phone number ID")); + } + + /** A saved connector's token is used server-side only, and only by the connector's owner. */ + private Credentials fromConnector(WhatsAppMetaCheckRequest request) { + SmsConnector connector = connectorRepository.findById(request.connectorId()) + .filter(found -> "WHATSAPP".equalsIgnoreCase(found.getProvider()) && isOwnedByCurrentUser(found)) + .orElseThrow(() -> new MetaGraphException("NOT_FOUND", "WhatsApp connector not found.")); + boolean tokenOnly = Boolean.TRUE.equals(request.tokenOnly()); + String waba = Optional.ofNullable(blankToNull(request.businessAccountId())).orElseGet(() -> tokenOnly ? null : businessAccountIdOf(connector)); + String phone = Optional.ofNullable(blankToNull(request.phoneNumberId())).orElse(tokenOnly ? null : connector.getSenderId()); + return new Credentials(MetaGraphClient.validatedBaseUrl(connector.getUrl()), connector.getKey(), + metaId(waba, "WhatsApp Business Account ID"), metaId(phone, "Phone number ID")); + } + + private boolean isOwnedByCurrentUser(SmsConnector connector) { + Optional user = currentUserResolver.getCurrentUser(); + return user.isPresent() && connector.getCreatedBy() != null && connector.getCreatedBy().getId().equals(user.get().getId()); + } + + private String businessAccountIdOf(SmsConnector connector) { + if (connector.getExtraSettings() == null || connector.getExtraSettings().isBlank()) { + return null; + } + try { + return WhatsAppPayloads.textOr(objectMapper.readValue(connector.getExtraSettings(), Map.class).get("businessAccountId"), null); + } catch (JsonProcessingException e) { + return null; + } + } + + /** IDs go into the request path, so anything but digits is rejected rather than escaped. */ + private static String metaId(String value, String label) { + String trimmed = blankToNull(value); + if (trimmed != null && !META_ID.matcher(trimmed).matches()) { + throw new MetaGraphException("INVALID_ID", label + " must be the numeric ID from Meta."); + } + return trimmed; + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorServiceTest.java new file mode 100644 index 0000000..b9ac8d7 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsConnectorServiceTest.java @@ -0,0 +1,91 @@ +package com.flexcodelabs.flextuma.modules.sms.services; + +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; +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 java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SmsConnectorServiceTest { + + @Mock + private SmsConnectorRepository repository; + + @Mock + private CurrentUserResolver userResolver; + + @InjectMocks + private SmsConnectorService service; + + private static User user() { + User user = new User(); + user.setId(UUID.randomUUID()); + return user; + } + + private static SmsConnector saved(User owner, String provider) { + SmsConnector source = new SmsConnector(); + source.setId(UUID.randomUUID()); + source.setProvider(provider); + source.setUrl("https://graph.facebook.com/v22.0"); + source.setKey("EAAG-saved-token"); + source.setSenderId("104725069208652"); + source.setCreatedBy(owner); + return source; + } + + private static SmsConnector newWhatsAppConnector(UUID reuseKeyFrom) { + SmsConnector connector = new SmsConnector(); + connector.setProvider("WHATSAPP"); + connector.setSenderId("404725069208999"); + connector.setReuseKeyFrom(reuseKeyFrom); + return connector; + } + + @Test + void onPreSave_shouldCopyKeyAndUrl_fromTheOwnersSavedConnector() { + User owner = user(); + SmsConnector source = saved(owner, "WHATSAPP"); + when(repository.findById(source.getId())).thenReturn(Optional.of(source)); + when(userResolver.getCurrentUser()).thenReturn(Optional.of(owner)); + SmsConnector connector = newWhatsAppConnector(source.getId()); + + service.onPreSave(connector); + + assertEquals("EAAG-saved-token", connector.getKey()); + assertEquals("https://graph.facebook.com/v22.0", connector.getUrl()); + assertEquals("404725069208999", connector.getSenderId()); + } + + @Test + void onPreSave_shouldRefuseAnotherUsersConnector() { + SmsConnector source = saved(user(), "WHATSAPP"); + when(repository.findById(source.getId())).thenReturn(Optional.of(source)); + when(userResolver.getCurrentUser()).thenReturn(Optional.of(user())); + SmsConnector connector = newWhatsAppConnector(source.getId()); + + assertThrows(IllegalArgumentException.class, () -> service.onPreSave(connector)); + } + + @Test + void onPreSave_shouldRefuseAConnectorOfAnotherProvider() { + User owner = user(); + SmsConnector source = saved(owner, "BEEM"); + when(repository.findById(source.getId())).thenReturn(Optional.of(source)); + SmsConnector connector = newWhatsAppConnector(source.getId()); + + assertThrows(IllegalArgumentException.class, () -> service.onPreSave(connector)); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaServiceTest.java new file mode 100644 index 0000000..cb77a15 --- /dev/null +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/services/meta/WhatsAppMetaServiceTest.java @@ -0,0 +1,214 @@ +package com.flexcodelabs.flextuma.modules.whatsapp.services.meta; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector; +import com.flexcodelabs.flextuma.core.helpers.CurrentUserResolver; +import com.flexcodelabs.flextuma.core.repositories.SmsConnectorRepository; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckRequest; +import com.flexcodelabs.flextuma.modules.whatsapp.dtos.WhatsAppMetaCheckResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.startsWith; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WhatsAppMetaServiceTest { + private static final String GRAPH = "https://graph.facebook.com/v21.0"; + private static final String WABA = "102290129340398"; + private static final String PHONE = "104725069208652"; + + @Mock private RestTemplate restTemplate; + @Mock private SmsConnectorRepository connectorRepository; + @Mock private CurrentUserResolver currentUserResolver; + + private WhatsAppMetaService service; + + @BeforeEach + void setUp() { + ObjectMapper objectMapper = new ObjectMapper(); + service = new WhatsAppMetaService(new MetaGraphClient(restTemplate, objectMapper), connectorRepository, currentUserResolver, objectMapper); + } + + private void stubGet(String path, Map body) { + when(restTemplate.exchange(startsWith(GRAPH + path), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(body)); + } + + private static Map phone(String id, String display) { + return Map.of("id", id, "display_phone_number", display, "verified_name", "Flextuma Support", "quality_rating", "GREEN"); + } + + private static WhatsAppMetaCheckRequest typed(String waba, String phoneId) { + return new WhatsAppMetaCheckRequest(GRAPH, "EAAG-token", waba, phoneId, null); + } + + @Test + void check_shouldListWabaNumbersAndSubscribedApps() { + stubGet("/" + WABA + "/phone_numbers", Map.of("data", List.of(phone(PHONE, "+255 700 000 001"), phone("999", "+255 700 000 002")))); + stubGet("/" + WABA + "/subscribed_apps", Map.of("data", List.of(Map.of("whatsapp_business_api_data", Map.of("name", "Flextuma", "id", "1"))))); + + WhatsAppMetaCheckResult result = service.check(typed(WABA, PHONE)); + + assertTrue(result.ok()); + assertEquals(2, result.phoneNumbers().size()); + assertEquals("+255 700 000 001", result.phoneNumber().displayPhoneNumber()); + assertEquals("Flextuma Support", result.phoneNumber().verifiedName()); + assertEquals(List.of("Flextuma"), result.subscribedApps()); + } + + @Test + void check_shouldSendTheTokenAsBearerAuth() { + stubGet("/" + PHONE, phone(PHONE, "+255 700 000 001")); + + service.check(typed(null, PHONE)); + + ArgumentCaptor entity = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(startsWith(GRAPH + "/" + PHONE), eq(HttpMethod.GET), entity.capture(), eq(Map.class)); + assertEquals("Bearer EAAG-token", entity.getValue().getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + } + + @Test + void check_shouldFail_whenPhoneNumberIsNotInTheWaba() { + stubGet("/" + WABA + "/phone_numbers", Map.of("data", List.of(phone("999", "+255 700 000 002")))); + + WhatsAppMetaCheckResult result = service.check(typed(WABA, PHONE)); + + assertFalse(result.ok()); + assertEquals("NOT_IN_WABA", result.errorCode()); + } + + @Test + void check_shouldExplainAnExpiredToken() { + byte[] body = "{\"error\":{\"message\":\"Error validating access token\",\"code\":190}}".getBytes(StandardCharsets.UTF_8); + when(restTemplate.exchange(any(String.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenThrow(HttpClientErrorException.create(HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, body, StandardCharsets.UTF_8)); + + WhatsAppMetaCheckResult result = service.check(typed(null, PHONE)); + + assertFalse(result.ok()); + assertEquals("190", result.errorCode()); + assertTrue(result.error().contains("invalid or has expired")); + } + + @Test + void check_shouldRefuseNonGraphHosts_withoutMakingARequest() { + WhatsAppMetaCheckResult result = service.check(new WhatsAppMetaCheckRequest("https://internal.example.com", "EAAG", null, PHONE, null)); + + assertEquals("INVALID_URL", result.errorCode()); + verify(restTemplate, never()).exchange(any(String.class), any(HttpMethod.class), any(HttpEntity.class), eq(Map.class)); + } + + @Test + void check_shouldRejectNonNumericIds_soTheyCannotAlterTheRequestPath() { + WhatsAppMetaCheckResult result = service.check(typed(null, "123/../me")); + + assertEquals("INVALID_ID", result.errorCode()); + } + + @Test + void check_shouldRequireAToken() { + assertEquals("MISSING_TOKEN", service.check(new WhatsAppMetaCheckRequest(GRAPH, " ", null, PHONE, null)).errorCode()); + } + + @Test + void check_shouldNotUseAnotherUsersConnector() { + SmsConnector connector = new SmsConnector(); + connector.setProvider("WHATSAPP"); + User owner = new User(); + owner.setId(UUID.randomUUID()); + connector.setCreatedBy(owner); + User someoneElse = new User(); + someoneElse.setId(UUID.randomUUID()); + UUID id = UUID.randomUUID(); + when(connectorRepository.findById(id)).thenReturn(Optional.of(connector)); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(someoneElse)); + + WhatsAppMetaCheckResult result = service.check(new WhatsAppMetaCheckRequest(null, null, null, null, id)); + + assertEquals("NOT_FOUND", result.errorCode()); + } + + @Test + void check_shouldUseASavedConnectorsTokenAndIds() { + SmsConnector connector = new SmsConnector(); + connector.setProvider("WHATSAPP"); + connector.setUrl(GRAPH); + connector.setKey("saved-token"); + connector.setSenderId(PHONE); + User owner = new User(); + owner.setId(UUID.randomUUID()); + connector.setCreatedBy(owner); + UUID id = UUID.randomUUID(); + when(connectorRepository.findById(id)).thenReturn(Optional.of(connector)); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(owner)); + stubGet("/" + PHONE, phone(PHONE, "+255 700 000 001")); + + WhatsAppMetaCheckResult result = service.check(new WhatsAppMetaCheckRequest(null, null, null, null, id)); + + assertTrue(result.ok()); + assertEquals(PHONE, result.phoneNumber().id()); + } + + @Test + void subscribe_shouldPostSubscribedApps_thenReturnTheRefreshedCheck() { + when(restTemplate.exchange(eq(GRAPH + "/" + WABA + "/subscribed_apps"), eq(HttpMethod.POST), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("success", true))); + stubGet("/" + WABA + "/phone_numbers", Map.of("data", List.of(phone(PHONE, "+255 700 000 001")))); + stubGet("/" + WABA + "/subscribed_apps", Map.of("data", List.of(Map.of("whatsapp_business_api_data", Map.of("name", "Flextuma"))))); + + WhatsAppMetaCheckResult result = service.subscribe(typed(WABA, null)); + + assertTrue(result.ok()); + assertEquals(List.of("Flextuma"), result.subscribedApps()); + } + + @Test + void check_withTokenOnly_shouldListAnotherWabaWithoutDefaultingToTheConnectorsNumber() { + SmsConnector connector = new SmsConnector(); + connector.setProvider("WHATSAPP"); + connector.setUrl(GRAPH); + connector.setKey("saved-token"); + connector.setSenderId(PHONE); + connector.setExtraSettings("{\"businessAccountId\":\"" + WABA + "\"}"); + User owner = new User(); + owner.setId(UUID.randomUUID()); + connector.setCreatedBy(owner); + UUID id = UUID.randomUUID(); + when(connectorRepository.findById(id)).thenReturn(Optional.of(connector)); + when(currentUserResolver.getCurrentUser()).thenReturn(Optional.of(owner)); + stubGet("/402290129340222/phone_numbers", Map.of("data", List.of(phone("404725069208999", "+254 700 000 999")))); + stubGet("/402290129340222/subscribed_apps", Map.of("data", List.of())); + + WhatsAppMetaCheckResult result = service.check(new WhatsAppMetaCheckRequest(null, null, "402290129340222", null, id, true)); + + assertTrue(result.ok()); + assertEquals(1, result.phoneNumbers().size()); + assertEquals(null, result.phoneNumber()); + } +} From f79fcb9f2ddb1baad17d08d336579f484542b47f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 21 Oct 2026 19:28:23 +0000 Subject: [PATCH 3/3] Release v0.0.85 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 516c9d2..4e44d81 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.84' +version = '0.0.85' description = 'Flextuma App' java {