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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Create a `.env` file in the root directory or export the variables in your shell
| `SPRING_DATA_REDIS_PORT` | ❌ | `6379` | Redis port |
| `HIKARI_MAX_POOL` | ❌ | `10` | Max JDBC connection pool size |
| `SMS_PRICE_PER_SEGMENT` | ❌ | `20.0` | Price per SMS segment (in TZS) |
| `FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS` | ❌ | `60000` | Beem delivery-report polling interval in milliseconds |
| `FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES` | ❌ | `5` | Minimum delay before the first Beem delivery lookup |

### 3. Build the application

Expand Down Expand Up @@ -329,12 +331,14 @@ POST /api/webhooks/{provider}
Content-Type: application/json

{
"messageId": "provider-message-id",
"request_id": "provider-request-id",
"status": "DELIVERED|FAILED|PENDING",
"timestamp": "2024-01-15T10:30:00Z"
}
```

For Beem, delivery reports are normally retrieved by the scheduled polling worker rather than a callback. The worker starts at least five minutes after submission, uses the Beem `request_id` stored as `providerMessageId`, and polls every 60 seconds by default. Configure the interval with `flextuma.sms.beem.delivery-poll-interval-ms`.

**Supported Providers:**
- `beem` - Beem SMS provider
- `next` - NextSMS provider
Expand Down Expand Up @@ -717,7 +721,7 @@ Two concrete `SmsSender` implementations:

| Provider | Class | Auth Method | Status |
|---|---|---|---|
| **Beem** | `BeemSender` | API key + secret (Basic Auth header) | ✅ Production ready |
| **Beem** | `BeemSender` + `BeemDeliveryReportWorker` | API key + secret (HTTP Basic); delivery-report polling | ✅ Production ready |
| **NextSMS** | `NextSmsSender` | API key + secret (Basic Auth header) | ✅ Production ready |

Adding a new provider: implement `SmsSender`, annotate with `@Service`, and set the matching `provider` string on the `SmsConnector` record.
Expand Down Expand Up @@ -771,7 +775,7 @@ Request with memberId
CSRF protection uses `CookieCsrfTokenRepository` (token sent as `XSRF-TOKEN` cookie, readable by SPA). Exemptions:

- `/api/login` — no session exists yet at this point
- `/api/webhooks/**` — reserved for PAT-authenticated provider callbacks
- `POST /api/webhooks/{provider}` — public provider callback endpoint; no custom secret header is required

### Tenant-Aware Resource Filtering

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.37'
version = '0.0.38'
description = 'Flextuma App'

java {
Expand Down
3 changes: 2 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ Provide secrets through the platform secret manager, never in the image, reposit
| `HIKARI_MAX_POOL`, `HIKARI_MIN_IDLE` | Recommended | Size across all replicas below PostgreSQL’s connection limit. |
| `SESSION_TIMEOUT` | Recommended | Session lifetime, e.g. `30m`. |
| `SMS_PRICE_PER_SEGMENT` | Yes | Decimal cost used for wallet accounting; confirm the business unit and currency. |
| `FLEXTUMA_WEBHOOKS_SMS_SHARED_SECRET` | Yes, for DLRs | Shared callback secret supplied by providers in `X-Flextuma-Webhook-Secret`; use a high-entropy secret from the secret manager. |
| `FLEXTUMA_SMS_BEEM_DELIVERY_POLL_INTERVAL_MS` | Optional | Beem delivery-report polling interval in milliseconds; defaults to `60000`. Beem polling starts five minutes after send. |
| `FLEXTUMA_SMS_BEEM_DELIVERY_MINIMUM_DELAY_MINUTES` | Optional | Minimum wait before the first Beem delivery lookup; defaults to `5`, as recommended by Beem. |
| `APP_FRONTEND_DIRECTORY` | If serving UI | Read-only directory containing `index.html` and assets. |
| `APP_UPLOAD_DIRECTORY` | If app uploads are enabled | Durable, access-controlled storage; `/tmp` loses uploads on restart. |
| `LOG_MIN_LEVEL`, `LOG_RETENTION_DAYS` | Recommended | Tune for operating requirements; database log retention has storage impact. |
Expand Down
7 changes: 4 additions & 3 deletions docs/frontend-design-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -430,8 +430,8 @@ Both follow the same pattern:
| URL | Text | Provider API base URL |
| API Key | Password input | Write-only; shows masked on read |
| Secret | Password input | Write-only; shows masked on read |
| Sender ID | Text | Optional — display name on SMS |
| Extra Settings | JSON editor or key-value pairs | Optional |
| Sender ID | Text | Required for BEEM and NEXT; must be an active provider sender ID |
| Extra Settings | JSON editor or key-value pairs | For BEEM, optional `encoding` and `schedule_time` fields, e.g. `{"encoding":"0","schedule_time":""}` |
| Default | Toggle | One connector should be default |

> **Important:** `key` and `secret` are write-only. On GET, the API returns masked values (e.g. `****abcd`). The form should leave these fields blank on edit and only send them if the user explicitly types new values.
Expand Down Expand Up @@ -986,9 +986,10 @@ When creating or updating entities via `POST` / `PUT` requests, submit a JSON bo
"key": "api-key-here",
"secret": "api-secret-here",
"isDefault": true,
"extraSettings": "{\"timeout\": 5000}"
"extraSettings": "{\"encoding\":\"0\",\"schedule_time\":\"\"}"
}
```
* **BEEM:** Use `https://apisms.beem.africa/v1/send`; `key`, `secret`, and an active `senderId` are required. `extraSettings.schedule_time` is optional GMT+0 in `yyyy-mm-dd hh:mm` format.
* **key, secret:** Write-only fields. Appears masked (`****abcd`) in reads. Do not send on `PUT` unless changing.

### 13.9 ConnectorConfig
Expand Down
41 changes: 33 additions & 8 deletions docs/third-party-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Flextuma currently integrates with:

| Party | Direction | Mechanism |
| --- | --- | --- |
| BEEM SMS | Outbound SMS; intended inbound delivery reports | JSON over HTTPS using provider credentials. |
| BEEM SMS | Outbound SMS and delivery-report polling | JSON over HTTPS using HTTP Basic credentials. |
| NextSMS | Outbound SMS; intended inbound delivery reports | JSON over HTTPS using Basic authentication. |
| Tenant/customer data API | Outbound recipient lookup and member hydration | Configurable GET requests plus JSONPath field mapping. |
| Client/automation | Inbound API requests | Session cookie or personal access token (PAT) in `X-API-KEY`. |
Expand All @@ -23,22 +23,47 @@ X-API-KEY: ft_<token-value>

PATs act as the owning user and inherit that user’s privileges. Use one token per integration, give it an expiry, rotate it, and disable/delete it when no longer needed. Do not put PATs in browser code, query strings, logs, or support tickets.

All API paths other than login, registration, and frontend assets require authentication under the active security configuration. In particular, there is no separate, functioning API-key scheme for anonymous webhooks despite the `flextuma.auth.api-key-endpoints` configuration property.
All API paths other than login, registration, frontend assets, and the one-segment DLR callback path (`POST /api/webhooks/{provider}`) require authentication under the active security configuration. There is no separate, functioning API-key scheme for anonymous webhooks despite the `flextuma.auth.api-key-endpoints` configuration property.

## SMS provider setup

Create an SMS connector using `POST /api/connectors` with the provider string, provider endpoint, credentials, and sender ID. The provider names implemented by the service are `BEEM` and `NEXT` (case-insensitive at send time). Connector key and secret fields are write-only/masked in API responses; retain the original values in your secret manager.

### BEEM

The BEEM adapter sends a JSON request with `source_addr`, `message`, `schedule_time`, `encoding`, and a one-item `recipients` list. It supplies credentials both as `api_key`/`secret_key` headers and HTTP Basic authentication. Configure the provider endpoint and sender ID supplied by BEEM. Confirm the expected response contains an actual provider message ID before enabling delivery reports.
Configure the connector with `provider: "BEEM"`, URL `https://apisms.beem.africa/v1/send`, the BEEM API key in `key`, the BEEM secret key in `secret`, and an active BEEM sender ID in `senderId`. The adapter sends HTTP Basic authentication (`key:secret`) and this JSON body:

```json
{
"source_addr": "ACME",
"schedule_time": "",
"encoding": "0",
"message": "Hello world",
"recipients": [{ "recipient_id": "1", "dest_addr": "255700000001" }]
}
```

Set optional Beem request fields through `extraSettings`, for example `{"encoding":"0","schedule_time":"2026-08-15 10:30"}`. `schedule_time` is GMT+0 in `yyyy-mm-dd hh:mm` format. Beem returns `request_id`; Flextuma stores it as `providerMessageId` for delivery tracking.

### NextSMS

The NextSMS adapter sends `{ "from", "to", "text" }` as JSON and uses HTTP Basic authentication (`key:secret`). It records `messages[0].messageId` when provided. Configure the exact NextSMS endpoint and approved sender ID from the provider account.

### Delivery reports (DLRs)

#### BEEM polling (primary)

Beem’s documented delivery mechanism is polling, not callback registration. Starting five minutes after a successful send, Flextuma polls the following endpoint for BEEM messages in `SENT` status:

```http
GET https://dlrapi.beem.africa/public/v1/delivery-reports?dest_addr={recipient}&request_id={providerMessageId}
Authorization: Basic base64(key:secret)
```

The polling interval defaults to 60 seconds and is configurable with `flextuma.sms.beem.delivery-poll-interval-ms`. A Beem `DELIVERED` status becomes `DELIVERED`, `UNDELIVERED` becomes `FAILED`, and `PENDING` remains `SENT` until a terminal status is returned.

#### Provider callbacks (optional)

The implemented route is:

```http
Expand All @@ -47,9 +72,9 @@ POST https://<public-host>/api/webhooks/NEXT
Content-Type: application/json
```

BEEM parser expects `messageID` and `status`; Next parser expects `message_id` (or `messageId`) and `status`. The documented source comment incorrectly describes `/api/webhooks/sms/{provider}/dlr`; that route does not exist.
The callback path is public and accepts no custom authentication header. Beem callbacks are correlated with `request_id` (with legacy `messageID` accepted) plus `status`; Next callbacks use `message_id` (or `messageId`) plus `status`. The callback route is optional for Beem because polling is the supported integration.

DLR routes are public only for the one-segment callback path and require the `X-Flextuma-Webhook-Secret` header. Set `FLEXTUMA_WEBHOOKS_SMS_SHARED_SECRET` from the deployment secret manager and configure the same high-entropy value at the provider. Requests with a missing/incorrect secret are rejected. The callback now correlates using `providerMessageId`; verify the provider’s response/callback message-ID shape in staging before enabling it.
DLR routes are public only for the one-segment callback path. The callback correlates using `providerMessageId`; validate the provider callback payload in staging before relying on it.

## Tenant/customer data API

Expand Down Expand Up @@ -77,8 +102,8 @@ These are code-observed findings as of this repository revision, ordered by impa

| Priority | Finding | Impact and recommended action |
| --- | --- | --- |
| Resolved | DLR endpoint authentication and lookup were incompatible with provider callbacks. | The callback path is now narrowly public, requires a constant-time compared shared secret, and correlates with `providerMessageId`. Upgrade next to provider-specific HMAC signatures, timestamp/replay controls, and IP allowlists where supported. |
| Resolved | BEEM did not retain its response message ID. | The adapter now reads `message_id` and saves it as `providerMessageId`; validate the exact live provider schema during staging. |
| Resolved | BEEM delivery tracking used a callback-only shape that did not match the documented API. | The adapter stores Beem `request_id` and polls the documented delivery endpoint after five minutes. |
| Resolved | Generic callback IDs did not match Beem delivery identifiers. | The public callback parser accepts Beem `request_id` (and legacy `messageID`) and correlates through `providerMessageId`. |
| Resolved | Generic single-record read, update, and delete skipped the tenant specification. | These operations now use the tenant-scoped specification. Maintain cross-tenant authorization tests as new endpoints are added. |
| Resolved | Raw dispatch used `content` while the queue required `message`. | The trigger now maps its request content to the required queue field. |
| Resolved | PAT authentication ignored a token’s `active` flag. | Inactive tokens are now rejected. |
Expand All @@ -94,4 +119,4 @@ These are code-observed findings as of this repository revision, ordered by impa

## Minimum acceptance tests

Before enabling any external party in production, automate these tests: valid and invalid PAT authentication; provider credential rejection; one successful send and one provider failure; idempotent retry behavior; a signed DLR that changes the correct SMS log; malformed/duplicate/out-of-order DLRs; tenant API timeout/5xx/oversize response; recipient pagination; wallet debit/refund reconciliation; and authorization isolation between organisations.
Before enabling any external party in production, automate these tests: valid and invalid PAT authentication; provider credential rejection; one successful send and one provider failure; Beem `request_id` persistence; Beem delivery polling for `PENDING`, `DELIVERED`, and `UNDELIVERED`; malformed/duplicate/out-of-order callbacks; tenant API timeout/5xx/oversize response; recipient pagination; wallet debit/refund reconciliation; and authorization isolation between organisations.
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ public String getMaskedKey() {
public String getMaskedSecret() {
return MaskingUtil.mask(this.secret);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public interface SmsLogRepository extends BaseRepository<SmsLog, UUID>,

List<SmsLog> findTop50ByStatusOrderByCreatedAsc(SmsLogStatus status);

List<SmsLog> 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")
List<SmsLog> findDueMessages(
@org.springframework.data.repository.query.Param("status") SmsLogStatus status,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.flexcodelabs.flextuma.core.senders;

import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;

import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.flexcodelabs.flextuma.core.entities.sms.SmsConnector;

import lombok.Data;

/** Client for Beem's documented delivery-report lookup API. */
@Service
public class BeemDeliveryReportClient {

static final String DELIVERY_REPORTS_URL = "https://dlrapi.beem.africa/public/v1/delivery-reports";

private final RestTemplate restTemplate;

public BeemDeliveryReportClient(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}

public BeemDeliveryStatus lookup(SmsConnector connector, String recipient, String requestId) {
String url = UriComponentsBuilder.fromUriString(DELIVERY_REPORTS_URL)
.queryParam("dest_addr", recipient)
.queryParam("request_id", requestId)
.toUriString();

ResponseEntity<List<BeemDeliveryStatus>> response = restTemplate.exchange(url, HttpMethod.GET,
new HttpEntity<Void>(headers(connector)), new ParameterizedTypeReference<>() {
});
List<BeemDeliveryStatus> reports = response.getBody();
return reports == null || reports.isEmpty() ? null : reports.get(0);
}

private HttpHeaders headers(SmsConnector connector) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String credentials = connector.getKey() + ":" + connector.getSecret();
headers.set(HttpHeaders.AUTHORIZATION, "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8)));
return headers;
}

@Data
public static class BeemDeliveryStatus {
@JsonProperty("dest_addr")
private String destinationAddress;

@JsonProperty("request_id")
private String requestId;

private String status;
}
}
Loading