From 23b13b8f72443436a7069da25ff89f2165c7dbbe Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sun, 14 Jun 2026 09:12:12 +0100 Subject: [PATCH] Complete Campaign API support --- README.md | 24 ++- docs/API_COVERAGE.md | 4 + .../Campaigns/CampaignHistoryResponse.cs | 57 +++++ src/Termii/Campaigns/CampaignListResponse.cs | 45 ++++ .../Campaigns/CampaignOperationResponse.cs | 8 + src/Termii/Campaigns/GetCampaignsRequest.cs | 17 ++ src/Termii/Campaigns/ITermiiCampaignClient.cs | 16 ++ src/Termii/Campaigns/SendCampaignRequest.cs | 54 +++++ src/Termii/Campaigns/SendCampaignResponse.cs | 13 ++ src/Termii/Campaigns/TermiiCampaignClient.cs | 59 +++++ .../Termii.Tests/TermiiCampaignClientTests.cs | 201 ++++++++++++++++++ 11 files changed, 497 insertions(+), 1 deletion(-) create mode 100644 src/Termii/Campaigns/CampaignHistoryResponse.cs create mode 100644 src/Termii/Campaigns/CampaignListResponse.cs create mode 100644 src/Termii/Campaigns/CampaignOperationResponse.cs create mode 100644 src/Termii/Campaigns/GetCampaignsRequest.cs create mode 100644 src/Termii/Campaigns/SendCampaignRequest.cs create mode 100644 src/Termii/Campaigns/SendCampaignResponse.cs diff --git a/README.md b/README.md index 3be1f9d..6f4dc35 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,28 @@ var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookR }); ``` +Send a campaign to a phonebook: + +```csharp +var campaign = await client.Campaigns.SendAsync(new SendCampaignRequest +{ + CountryCode = "234", + SenderId = "Termii", + Message = "Welcome to Termii.", + Channel = "generic", + MessageType = "Plain", + PhonebookId = "phonebook-123", + CampaignType = "regular", + ScheduleSmsStatus = "regular" +}); +``` + +Fetch campaign history: + +```csharp +var history = await client.Campaigns.GetCampaignHistoryAsync("C714360330258"); +``` + ## Contacts Add a single contact to a phonebook: @@ -362,7 +384,7 @@ Implemented in the current SDK: - Number API: send message through a dedicated Termii number. - Tokens: send, verify, generate, voice, email, and WhatsApp OTP flows. - Insights: balance, DND status, number intelligence, message history, and message analytics. -- Campaigns: list, create, update, and delete phonebooks. +- Campaigns: send campaigns, list campaigns, fetch campaign history, retry failed campaigns, and manage phonebooks. - Contacts: list, add, upload, and delete phonebook contacts. - Product emails: send template-based notification emails. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index f0a751a..47fd2d1 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -60,6 +60,10 @@ The Termii docs describe a REST/JSON API and state that each account has its own | Insights | Query number intelligence/status | GET | `/api/insight/number/query` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message inbox/history | GET | `/api/sms/inbox` | Query | `TermiiClient.Insights` | Implemented | #9, PR #27 | Unit tests added | | Insights | Fetch message analytics/history | GET | `/api/sms/history/analytics` | Query | `TermiiClient.Insights` | Implemented | #34, PR #35 | Unit tests added | +| Campaigns | Send campaign | POST | `/api/sms/campaigns/send` | JSON body | `TermiiClient.Campaigns` | Implemented | #44 | Unit tests added | +| Campaigns | Fetch campaigns | GET | `/api/sms/campaigns` | Query | `TermiiClient.Campaigns` | Implemented | #44 | Unit tests added | +| Campaigns | Fetch campaign history | GET | `/api/sms/campaigns/{campaign_id}` | Query | `TermiiClient.Campaigns` | Implemented | #44 | Unit tests added | +| Campaigns | Retry failed campaign | PATCH | `/api/sms/campaigns/{campaign_id}` | JSON body | `TermiiClient.Campaigns` | Implemented | #44 | Unit tests added | | Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | | Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | | Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | Implemented | #33, PR #38 | Unit tests added | diff --git a/src/Termii/Campaigns/CampaignHistoryResponse.cs b/src/Termii/Campaigns/CampaignHistoryResponse.cs new file mode 100644 index 0000000..66eb42f --- /dev/null +++ b/src/Termii/Campaigns/CampaignHistoryResponse.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class CampaignHistoryResponse +{ + public string? Id { get; set; } + + public int? ApplicationId { get; set; } + + public string? Uuid { get; set; } + + public string? CreatedAt { get; set; } + + public string? UpdatedAt { get; set; } + + public string? CampaignId { get; set; } + + public string? PhonebookId { get; set; } + + public string? PhonebookName { get; set; } + + public string? Sender { get; set; } + + public string? Message { get; set; } + + public string? CountryCode { get; set; } + + public string? SmsType { get; set; } + + public string? CampaignType { get; set; } + + public string? Status { get; set; } + + public decimal? Cost { get; set; } + + public int? TotalRecipient { get; set; } + + public int? TotalDelivered { get; set; } + + public int? TotalFailed { get; set; } + + public int? Sent { get; set; } + + public string? RunAt { get; set; } + + public bool? IsLinkTrackingEnabled { get; set; } + + public bool? Rerun { get; set; } + + public string? SendBy { get; set; } + + public bool? Personalized { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/CampaignListResponse.cs b/src/Termii/Campaigns/CampaignListResponse.cs new file mode 100644 index 0000000..3555064 --- /dev/null +++ b/src/Termii/Campaigns/CampaignListResponse.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class CampaignListResponse +{ + public IReadOnlyList Content { get; set; } = Array.Empty(); + + public int? TotalPages { get; set; } + + public int? TotalElements { get; set; } + + public int? Size { get; set; } + + public int? Number { get; set; } + + public bool? First { get; set; } + + public bool? Last { get; set; } + + public bool? Empty { get; set; } +} + +public sealed class CampaignRecord +{ + [JsonPropertyName("campaign_id")] + public string? CampaignId { get; set; } + + [JsonPropertyName("run_at")] + public string? RunAt { get; set; } + + public string? Status { get; set; } + + [JsonPropertyName("created_at")] + public long? CreatedAt { get; set; } + + [JsonPropertyName("phone_book")] + public string? Phonebook { get; set; } + + [JsonPropertyName("camp_type")] + public string? CampaignType { get; set; } + + [JsonPropertyName("total_recipients")] + public int? TotalRecipients { get; set; } +} diff --git a/src/Termii/Campaigns/CampaignOperationResponse.cs b/src/Termii/Campaigns/CampaignOperationResponse.cs new file mode 100644 index 0000000..e563ce4 --- /dev/null +++ b/src/Termii/Campaigns/CampaignOperationResponse.cs @@ -0,0 +1,8 @@ +namespace Termii; + +public sealed class CampaignOperationResponse +{ + public string? Message { get; set; } + + public string? Status { get; set; } +} diff --git a/src/Termii/Campaigns/GetCampaignsRequest.cs b/src/Termii/Campaigns/GetCampaignsRequest.cs new file mode 100644 index 0000000..9e77038 --- /dev/null +++ b/src/Termii/Campaigns/GetCampaignsRequest.cs @@ -0,0 +1,17 @@ +namespace Termii; + +public sealed class GetCampaignsRequest +{ + public int? Page { get; set; } + + public int? Size { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiCampaignQueryString.AddIfPresent(query, "page", Page); + TermiiCampaignQueryString.AddIfPresent(query, "size", Size); + + return TermiiCampaignQueryString.Append("/api/sms/campaigns", query); + } +} diff --git a/src/Termii/Campaigns/ITermiiCampaignClient.cs b/src/Termii/Campaigns/ITermiiCampaignClient.cs index b70e29d..205da4b 100644 --- a/src/Termii/Campaigns/ITermiiCampaignClient.cs +++ b/src/Termii/Campaigns/ITermiiCampaignClient.cs @@ -2,6 +2,22 @@ namespace Termii; public interface ITermiiCampaignClient { + Task SendAsync( + SendCampaignRequest request, + CancellationToken cancellationToken = default); + + Task GetCampaignsAsync( + GetCampaignsRequest? request = null, + CancellationToken cancellationToken = default); + + Task GetCampaignHistoryAsync( + string campaignId, + CancellationToken cancellationToken = default); + + Task RetryAsync( + string campaignId, + CancellationToken cancellationToken = default); + Task GetPhonebooksAsync( GetPhonebooksRequest? request = null, CancellationToken cancellationToken = default); diff --git a/src/Termii/Campaigns/SendCampaignRequest.cs b/src/Termii/Campaigns/SendCampaignRequest.cs new file mode 100644 index 0000000..d4125f5 --- /dev/null +++ b/src/Termii/Campaigns/SendCampaignRequest.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendCampaignRequest +{ + [JsonPropertyName("country_code")] + public string CountryCode { get; set; } = string.Empty; + + [JsonPropertyName("sender_id")] + public string SenderId { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string Message { get; set; } = string.Empty; + + [JsonPropertyName("channel")] + public string Channel { get; set; } = string.Empty; + + [JsonPropertyName("message_type")] + public string MessageType { get; set; } = string.Empty; + + [JsonPropertyName("phonebook_id")] + public string PhonebookId { get; set; } = string.Empty; + + [JsonPropertyName("enable_link_tracking")] + public bool? EnableLinkTracking { get; set; } + + [JsonPropertyName("campaign_type")] + public string CampaignType { get; set; } = string.Empty; + + [JsonPropertyName("schedule_sms_status")] + public string ScheduleSmsStatus { get; set; } = string.Empty; + + [JsonPropertyName("schedule_time")] + public string? ScheduleTime { get; set; } + + [JsonPropertyName("delimiter")] + public string? Delimiter { get; set; } + + [JsonPropertyName("remove_duplicate")] + public string? RemoveDuplicate { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(CountryCode, nameof(CountryCode)); + TermiiRequestValidation.Required(SenderId, nameof(SenderId)); + TermiiRequestValidation.Required(Message, nameof(Message)); + TermiiRequestValidation.Required(Channel, nameof(Channel)); + TermiiRequestValidation.Required(MessageType, nameof(MessageType)); + TermiiRequestValidation.Required(PhonebookId, nameof(PhonebookId)); + TermiiRequestValidation.Required(CampaignType, nameof(CampaignType)); + TermiiRequestValidation.Required(ScheduleSmsStatus, nameof(ScheduleSmsStatus)); + } +} diff --git a/src/Termii/Campaigns/SendCampaignResponse.cs b/src/Termii/Campaigns/SendCampaignResponse.cs new file mode 100644 index 0000000..51ab629 --- /dev/null +++ b/src/Termii/Campaigns/SendCampaignResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class SendCampaignResponse +{ + public string? Message { get; set; } + + [JsonPropertyName("campaignId")] + public string? CampaignId { get; set; } + + public string? Status { get; set; } +} diff --git a/src/Termii/Campaigns/TermiiCampaignClient.cs b/src/Termii/Campaigns/TermiiCampaignClient.cs index 275caf5..40b05f9 100644 --- a/src/Termii/Campaigns/TermiiCampaignClient.cs +++ b/src/Termii/Campaigns/TermiiCampaignClient.cs @@ -11,6 +11,65 @@ public TermiiCampaignClient(TermiiJsonHttpPipeline pipeline) _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); } + public Task SendAsync( + SendCampaignRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/sms/campaigns/send", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task GetCampaignsAsync( + GetCampaignsRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetCampaignsRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task GetCampaignHistoryAsync( + string campaignId, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(campaignId, nameof(campaignId)); + + return _pipeline.SendJsonAsync( + HttpMethod.Get, + $"/api/sms/campaigns/{Uri.EscapeDataString(campaignId)}", + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task RetryAsync( + string campaignId, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(campaignId, nameof(campaignId)); + + return _pipeline.SendJsonAsync( + Patch, + $"/api/sms/campaigns/{Uri.EscapeDataString(campaignId)}", + body: null, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + public Task GetPhonebooksAsync( GetPhonebooksRequest? request = null, CancellationToken cancellationToken = default) diff --git a/tests/Termii.Tests/TermiiCampaignClientTests.cs b/tests/Termii.Tests/TermiiCampaignClientTests.cs index 0e56582..841beed 100644 --- a/tests/Termii.Tests/TermiiCampaignClientTests.cs +++ b/tests/Termii.Tests/TermiiCampaignClientTests.cs @@ -7,6 +7,186 @@ namespace Termii.Tests; public sealed class TermiiCampaignClientTests { + [Fact] + public async Task SendAsyncPostsCampaignBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.Created, + """{"message":"Your campaign has been scheduled","campaignId":"C714360330258","status":"success"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.SendAsync( + new SendCampaignRequest + { + CountryCode = "234", + SenderId = "Termii", + Message = "Welcome to Termii.", + Channel = "generic", + MessageType = "Plain", + PhonebookId = "pb-123", + Delimiter = ",", + RemoveDuplicate = "yes", + EnableLinkTracking = true, + CampaignType = "personalized", + ScheduleTime = "30-06-2026 6:00", + ScheduleSmsStatus = "scheduled", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal("https://example.test/api/sms/campaigns/send", request.RequestUri!.AbsoluteUri); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("234", body.RootElement.GetProperty("country_code").GetString()); + Assert.Equal("Termii", body.RootElement.GetProperty("sender_id").GetString()); + Assert.Equal("Welcome to Termii.", body.RootElement.GetProperty("message").GetString()); + Assert.Equal("generic", body.RootElement.GetProperty("channel").GetString()); + Assert.Equal("Plain", body.RootElement.GetProperty("message_type").GetString()); + Assert.Equal("pb-123", body.RootElement.GetProperty("phonebook_id").GetString()); + Assert.Equal(",", body.RootElement.GetProperty("delimiter").GetString()); + Assert.Equal("yes", body.RootElement.GetProperty("remove_duplicate").GetString()); + Assert.True(body.RootElement.GetProperty("enable_link_tracking").GetBoolean()); + Assert.Equal("personalized", body.RootElement.GetProperty("campaign_type").GetString()); + Assert.Equal("30-06-2026 6:00", body.RootElement.GetProperty("schedule_time").GetString()); + Assert.Equal("scheduled", body.RootElement.GetProperty("schedule_sms_status").GetString()); + Assert.Equal("Your campaign has been scheduled", response.Message); + Assert.Equal("C714360330258", response.CampaignId); + Assert.Equal("success", response.Status); + } + + [Fact] + public async Task GetCampaignsAsyncAddsFiltersAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "campaign_id": "C714360330258", + "run_at": "07-08-2026 13:00", + "status": "DELIVERED", + "created_at": 1754568051635, + "phone_book": "Customers", + "camp_type": "regular", + "total_recipients": 12 + } + ], + "totalPages": 1, + "totalElements": 1, + "size": 15, + "number": 0 + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.GetCampaignsAsync( + new GetCampaignsRequest + { + Page = 0, + Size = 15, + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var campaign = Assert.Single(response.Content); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://example.test/api/sms/campaigns?page=0&size=15&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal(1, response.TotalElements); + Assert.Equal("C714360330258", campaign.CampaignId); + Assert.Equal("07-08-2026 13:00", campaign.RunAt); + Assert.Equal("DELIVERED", campaign.Status); + Assert.Equal(1754568051635, campaign.CreatedAt); + Assert.Equal("Customers", campaign.Phonebook); + Assert.Equal("regular", campaign.CampaignType); + Assert.Equal(12, campaign.TotalRecipients); + } + + [Fact] + public async Task GetCampaignHistoryAsyncAddsEscapedCampaignIdAndApiKey() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "id": "688a686cbf5cd77880287d35", + "applicationId": 33217, + "campaignId": "C266806497506", + "phonebookId": "pb-123", + "phonebookName": "Customers", + "sender": "Termii", + "message": "Hello", + "countryCode": "234", + "smsType": "plain", + "campaignType": "regular", + "status": "DELIVERED", + "cost": 18.0, + "totalRecipient": 3, + "totalDelivered": 2, + "totalFailed": 1, + "sent": 3, + "runAt": "30-07-2026 19:46", + "isLinkTrackingEnabled": true, + "rerun": false, + "sendBy": "sender", + "personalized": false + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.GetCampaignHistoryAsync("C 123", CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal("https://example.test/api/sms/campaigns/C%20123?api_key=test-api-key", request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal("688a686cbf5cd77880287d35", response.Id); + Assert.Equal(33217, response.ApplicationId); + Assert.Equal("C266806497506", response.CampaignId); + Assert.Equal("pb-123", response.PhonebookId); + Assert.Equal("Customers", response.PhonebookName); + Assert.Equal("DELIVERED", response.Status); + Assert.Equal(18.0m, response.Cost); + Assert.Equal(3, response.TotalRecipient); + Assert.Equal(2, response.TotalDelivered); + Assert.Equal(1, response.TotalFailed); + Assert.True(response.IsLinkTrackingEnabled); + Assert.False(response.Rerun); + } + + [Fact] + public async Task RetryAsyncPatchesCampaignWithApiKeyBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"message":"Your failed campaign has been retried","status":"success"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.RetryAsync("C 123", CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + using var body = await request.ReadJsonBodyAsync(CancellationToken.None); + + Assert.Equal(new HttpMethod("PATCH"), request.Method); + Assert.Equal("https://example.test/api/sms/campaigns/C%20123", request.RequestUri!.AbsoluteUri); + var property = Assert.Single(body.RootElement.EnumerateObject()); + Assert.Equal("api_key", property.Name); + Assert.Equal("test-api-key", property.Value.GetString()); + Assert.Equal("Your failed campaign has been retried", response.Message); + Assert.Equal("success", response.Status); + } + [Fact] public async Task GetPhonebooksAsyncAddsFiltersAndDeserializesResponse() { @@ -145,4 +325,25 @@ await Assert.ThrowsAsync(() => client.Campaigns.CreatePhonebo }, CancellationToken.None)); } + + [Fact] + public async Task SendAsyncRejectsMissingRequiredFields() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Campaigns.SendAsync( + new SendCampaignRequest + { + CountryCode = "", + SenderId = "Termii", + Message = "Welcome", + Channel = "generic", + MessageType = "Plain", + PhonebookId = "pb-123", + CampaignType = "regular", + ScheduleSmsStatus = "regular", + }, + CancellationToken.None)); + } }