diff --git a/README.md b/README.md index 62ade0b..4ca9239 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,28 @@ app.MapPost("/webhooks/termii", (TermiiWebhookEvent webhookEvent) => Webhook payloads can vary by event type and Termii account configuration. Unknown fields are preserved in `TermiiWebhookEvent.AdditionalData`. +## Campaigns + +Fetch campaign phonebooks: + +```csharp +var phonebooks = await client.Campaigns.GetPhonebooksAsync(new GetPhonebooksRequest +{ + Page = 0, + Size = 15 +}); +``` + +Create a phonebook: + +```csharp +var phonebook = await client.Campaigns.CreatePhonebookAsync(new CreatePhonebookRequest +{ + PhonebookName = "Customers", + Description = "Customer contacts" +}); +``` + ## Error Handling The SDK throws `TermiiApiException` for non-success HTTP responses from Termii: @@ -285,11 +307,11 @@ 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. Deferred or not yet implemented: - WhatsApp template/device message APIs. -- Campaign phonebook APIs. - Product notification email APIs. See [docs/API_COVERAGE.md](docs/API_COVERAGE.md) for the detailed coverage matrix. diff --git a/docs/API_COVERAGE.md b/docs/API_COVERAGE.md index 8cbacfe..8792d26 100644 --- a/docs/API_COVERAGE.md +++ b/docs/API_COVERAGE.md @@ -58,6 +58,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` | In progress | #34 | Unit tests added | +| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | +| Campaigns | Create phonebook | POST | `/api/phonebooks` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | +| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | JSON body | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | +| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Query | `TermiiClient.Campaigns` | In progress | #33 | Unit tests added | ## Deferred Coverage @@ -67,10 +71,6 @@ The following documented APIs are useful but should come after the first SDK mil | --- | --- | --- | --- | --- | --- | | Messaging | Send WhatsApp template without media | POST | `/api/send/template` | Deferred | Requires WhatsApp template/device setup. | | Messaging | Send WhatsApp template with media | POST | `/api/send/template/media` | Deferred | Requires WhatsApp template/device setup and media payload modeling. | -| Campaigns | Fetch phonebooks | GET | `/api/phonebooks` | Deferred | Part of campaign/phonebook management, not core messaging. | -| Campaigns | Create phonebook | POST | `/api/phonebooks` | Deferred | Part of campaign/phonebook management. | -| Campaigns | Update phonebook | PATCH | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. | -| Campaigns | Delete phonebook | DELETE | `/api/phonebooks/{phonebook_id}` | Deferred | Part of campaign/phonebook management. | | Email | Send product notification email | POST | `/api/templates/send-email` | Deferred | Email notifications should be a separate milestone after SMS/token/insights. | | Insights | Webhook events and reports | N/A | Consumer webhook endpoint | Implemented | Receiver-side model support and README example covered by #32. | diff --git a/src/Termii/Campaigns/CreatePhonebookRequest.cs b/src/Termii/Campaigns/CreatePhonebookRequest.cs new file mode 100644 index 0000000..42e4b92 --- /dev/null +++ b/src/Termii/Campaigns/CreatePhonebookRequest.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class CreatePhonebookRequest +{ + [JsonPropertyName("phonebook_name")] + public string PhonebookName { get; set; } = string.Empty; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + internal void Validate() + { + TermiiRequestValidation.Required(PhonebookName, nameof(PhonebookName)); + } +} diff --git a/src/Termii/Campaigns/GetPhonebooksRequest.cs b/src/Termii/Campaigns/GetPhonebooksRequest.cs new file mode 100644 index 0000000..68dea34 --- /dev/null +++ b/src/Termii/Campaigns/GetPhonebooksRequest.cs @@ -0,0 +1,20 @@ +namespace Termii; + +public sealed class GetPhonebooksRequest +{ + public int? Page { get; set; } + + public int? Size { get; set; } + + public string? Name { get; set; } + + internal string ToPath() + { + var query = new List(); + TermiiCampaignQueryString.AddIfPresent(query, "page", Page); + TermiiCampaignQueryString.AddIfPresent(query, "size", Size); + TermiiCampaignQueryString.AddIfPresent(query, "name", Name); + + return TermiiCampaignQueryString.Append("/api/phonebooks", query); + } +} diff --git a/src/Termii/Campaigns/ITermiiCampaignClient.cs b/src/Termii/Campaigns/ITermiiCampaignClient.cs new file mode 100644 index 0000000..b70e29d --- /dev/null +++ b/src/Termii/Campaigns/ITermiiCampaignClient.cs @@ -0,0 +1,21 @@ +namespace Termii; + +public interface ITermiiCampaignClient +{ + Task GetPhonebooksAsync( + GetPhonebooksRequest? request = null, + CancellationToken cancellationToken = default); + + Task CreatePhonebookAsync( + CreatePhonebookRequest request, + CancellationToken cancellationToken = default); + + Task UpdatePhonebookAsync( + string phonebookId, + UpdatePhonebookRequest request, + CancellationToken cancellationToken = default); + + Task DeletePhonebookAsync( + string phonebookId, + CancellationToken cancellationToken = default); +} diff --git a/src/Termii/Campaigns/PhonebookListResponse.cs b/src/Termii/Campaigns/PhonebookListResponse.cs new file mode 100644 index 0000000..af4770c --- /dev/null +++ b/src/Termii/Campaigns/PhonebookListResponse.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookListResponse +{ + [JsonPropertyName("content")] + public IReadOnlyList Content { get; set; } = Array.Empty(); + + [JsonPropertyName("data")] + public IReadOnlyList? Data { get; set; } + + [JsonPropertyName("totalElements")] + public int? TotalElements { get; set; } + + [JsonPropertyName("totalPages")] + public int? TotalPages { get; set; } + + [JsonPropertyName("size")] + public int? Size { get; set; } + + [JsonPropertyName("number")] + public int? Number { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookOperationResponse.cs b/src/Termii/Campaigns/PhonebookOperationResponse.cs new file mode 100644 index 0000000..1336c13 --- /dev/null +++ b/src/Termii/Campaigns/PhonebookOperationResponse.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookOperationResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookRecord.cs b/src/Termii/Campaigns/PhonebookRecord.cs new file mode 100644 index 0000000..f13bc31 --- /dev/null +++ b/src/Termii/Campaigns/PhonebookRecord.cs @@ -0,0 +1,34 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookRecord +{ + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("phonebook_id")] + public string? PhonebookId { get; set; } + + [JsonPropertyName("phonebook_name")] + public string? PhonebookName { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("total_contacts")] + public int? TotalContacts { get; set; } + + [JsonPropertyName("created_at")] + public string? CreatedAt { get; set; } + + [JsonPropertyName("updated_at")] + public string? UpdatedAt { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/PhonebookResponse.cs b/src/Termii/Campaigns/PhonebookResponse.cs new file mode 100644 index 0000000..6ca11ee --- /dev/null +++ b/src/Termii/Campaigns/PhonebookResponse.cs @@ -0,0 +1,22 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class PhonebookResponse +{ + [JsonPropertyName("code")] + public string? Code { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("data")] + public PhonebookRecord? Data { get; set; } + + [JsonPropertyName("phonebook")] + public PhonebookRecord? Phonebook { get; set; } + + [JsonExtensionData] + public Dictionary? AdditionalData { get; set; } +} diff --git a/src/Termii/Campaigns/TermiiCampaignClient.cs b/src/Termii/Campaigns/TermiiCampaignClient.cs new file mode 100644 index 0000000..275caf5 --- /dev/null +++ b/src/Termii/Campaigns/TermiiCampaignClient.cs @@ -0,0 +1,78 @@ +namespace Termii; + +internal sealed class TermiiCampaignClient : ITermiiCampaignClient +{ + private static readonly HttpMethod Patch = new("PATCH"); + + private readonly TermiiJsonHttpPipeline _pipeline; + + public TermiiCampaignClient(TermiiJsonHttpPipeline pipeline) + { + _pipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline)); + } + + public Task GetPhonebooksAsync( + GetPhonebooksRequest? request = null, + CancellationToken cancellationToken = default) + { + return _pipeline.SendJsonAsync( + HttpMethod.Get, + (request ?? new GetPhonebooksRequest()).ToPath(), + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } + + public Task CreatePhonebookAsync( + CreatePhonebookRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + request.Validate(); + + return _pipeline.SendJsonAsync( + HttpMethod.Post, + "/api/phonebooks", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task UpdatePhonebookAsync( + string phonebookId, + UpdatePhonebookRequest request, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + return _pipeline.SendJsonAsync( + Patch, + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}", + request, + TermiiAuthenticationLocation.Body, + cancellationToken); + } + + public Task DeletePhonebookAsync( + string phonebookId, + CancellationToken cancellationToken = default) + { + TermiiRequestValidation.Required(phonebookId, nameof(phonebookId)); + + return _pipeline.SendJsonAsync( + HttpMethod.Delete, + $"/api/phonebooks/{Uri.EscapeDataString(phonebookId)}", + body: null, + TermiiAuthenticationLocation.Query, + cancellationToken); + } +} diff --git a/src/Termii/Campaigns/TermiiCampaignQueryString.cs b/src/Termii/Campaigns/TermiiCampaignQueryString.cs new file mode 100644 index 0000000..afbbc32 --- /dev/null +++ b/src/Termii/Campaigns/TermiiCampaignQueryString.cs @@ -0,0 +1,27 @@ +namespace Termii; + +internal static class TermiiCampaignQueryString +{ + public static void AddIfPresent(List query, string name, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + { + query.Add($"{name}={Uri.EscapeDataString(value)}"); + } + } + + public static void AddIfPresent(List query, string name, int? value) + { + if (value.HasValue) + { + query.Add($"{name}={value.Value}"); + } + } + + public static string Append(string path, List query) + { + return query.Count == 0 + ? path + : $"{path}?{string.Join("&", query)}"; + } +} diff --git a/src/Termii/Campaigns/UpdatePhonebookRequest.cs b/src/Termii/Campaigns/UpdatePhonebookRequest.cs new file mode 100644 index 0000000..cf6e7c8 --- /dev/null +++ b/src/Termii/Campaigns/UpdatePhonebookRequest.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Termii; + +public sealed class UpdatePhonebookRequest +{ + [JsonPropertyName("phonebook_name")] + public string? PhonebookName { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } +} diff --git a/src/Termii/TermiiClient.cs b/src/Termii/TermiiClient.cs index cd4a3bd..65d346b 100644 --- a/src/Termii/TermiiClient.cs +++ b/src/Termii/TermiiClient.cs @@ -39,6 +39,7 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp Numbers = new TermiiNumberClient(_pipeline); Tokens = new TermiiTokenClient(_pipeline); Insights = new TermiiInsightsClient(_pipeline); + Campaigns = new TermiiCampaignClient(_pipeline); } public TermiiOptions Options { get; } @@ -53,6 +54,8 @@ private TermiiClient(HttpClient httpClient, TermiiOptions options, bool ownsHttp public ITermiiInsightsClient Insights { get; } + public ITermiiCampaignClient Campaigns { get; } + public void Dispose() { if (_ownsHttpClient) diff --git a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs index 0f3b046..b8d6f83 100644 --- a/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs +++ b/tests/Termii.IntegrationTests/TermiiClientIntegrationTests.cs @@ -22,5 +22,6 @@ public void CanCreateClientFromIntegrationEnvironment() Assert.NotNull(client.Numbers); Assert.NotNull(client.Tokens); Assert.NotNull(client.Insights); + Assert.NotNull(client.Campaigns); } } diff --git a/tests/Termii.Tests/TermiiCampaignClientTests.cs b/tests/Termii.Tests/TermiiCampaignClientTests.cs new file mode 100644 index 0000000..0e56582 --- /dev/null +++ b/tests/Termii.Tests/TermiiCampaignClientTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using Termii; +using Termii.Tests.Infrastructure; +using Xunit; + +namespace Termii.Tests; + +public sealed class TermiiCampaignClientTests +{ + [Fact] + public async Task GetPhonebooksAsyncAddsFiltersAndDeserializesResponse() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "content": [ + { + "phonebook_id": "pb-123", + "phonebook_name": "Customers", + "description": "Customer contacts", + "total_contacts": 42, + "created_at": "2026-06-14 10:00:00" + } + ], + "totalElements": 1, + "totalPages": 1, + "size": 15, + "number": 0 + } + """); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.GetPhonebooksAsync( + new GetPhonebooksRequest + { + Page = 0, + Size = 15, + Name = "Customers", + }, + CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + var phonebook = Assert.Single(response.Content); + + Assert.Equal(HttpMethod.Get, request.Method); + Assert.Equal( + "https://example.test/api/phonebooks?page=0&size=15&name=Customers&api_key=test-api-key", + request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal(1, response.TotalElements); + Assert.Equal("pb-123", phonebook.PhonebookId); + Assert.Equal("Customers", phonebook.PhonebookName); + Assert.Equal(42, phonebook.TotalContacts); + } + + [Fact] + public async Task CreatePhonebookAsyncPostsJsonBody() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"code":"ok","message":"Phonebook created","data":{"phonebook_id":"pb-123","phonebook_name":"Customers"}}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.CreatePhonebookAsync( + new CreatePhonebookRequest + { + PhonebookName = "Customers", + Description = "Customer contacts", + }, + 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/phonebooks", request.RequestUri!.ToString()); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Customers", body.RootElement.GetProperty("phonebook_name").GetString()); + Assert.Equal("Customer contacts", body.RootElement.GetProperty("description").GetString()); + Assert.Equal("ok", response.Code); + Assert.Equal("pb-123", response.Data!.PhonebookId); + } + + [Fact] + public async Task UpdatePhonebookAsyncPatchesEscapedPhonebookPath() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"message":"Phonebook updated","phonebook":{"id":"pb 123","name":"Customers 2026"}}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.UpdatePhonebookAsync( + "pb 123", + new UpdatePhonebookRequest + { + PhonebookName = "Customers 2026", + }, + 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/phonebooks/pb%20123", request.RequestUri!.AbsoluteUri); + Assert.Equal("test-api-key", body.RootElement.GetProperty("api_key").GetString()); + Assert.Equal("Customers 2026", body.RootElement.GetProperty("phonebook_name").GetString()); + Assert.Equal("Phonebook updated", response.Message); + Assert.Equal("Customers 2026", response.Phonebook!.Name); + } + + [Fact] + public async Task DeletePhonebookAsyncAddsApiKeyToQueryString() + { + using var handler = new TestHttpMessageHandler( + HttpStatusCode.OK, + """{"status":"success","message":"Phonebook deleted"}"""); + var client = TestTermiiClientFactory.Create(handler); + + var response = await client.Campaigns.DeletePhonebookAsync("pb-123", CancellationToken.None); + + var request = handler.LastRequest; + Assert.NotNull(request); + + Assert.Equal(HttpMethod.Delete, request.Method); + Assert.Equal("https://example.test/api/phonebooks/pb-123?api_key=test-api-key", request.RequestUri!.AbsoluteUri); + Assert.Null(request.Content); + Assert.Equal("success", response.Status); + Assert.Equal("Phonebook deleted", response.Message); + } + + [Fact] + public async Task CreatePhonebookAsyncRejectsMissingName() + { + using var handler = new TestHttpMessageHandler(); + var client = TestTermiiClientFactory.Create(handler); + + await Assert.ThrowsAsync(() => client.Campaigns.CreatePhonebookAsync( + new CreatePhonebookRequest + { + PhonebookName = "", + }, + CancellationToken.None)); + } +}