diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bedaa..c243436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ ICustomerWrapper customers = client.Customer; - `Invoice.StampDraftAsync` and legacy `StampDraft` now co-exist; `StampDraft` is marked as obsolete. - Added initial `FacturapiTest` test project with regression coverage for query building and wrapper behavior. - Added `FacturapiClient.CreateWithCustomHttpClient(...)` for advanced scenarios where consumers need to provide their own `HttpClient` without changing the default constructor. +- Added organization team-management endpoints to `Organization` / `IOrganizationWrapper`: access listing and retrieval, invite send/cancel/respond flows, role listing/templates/operations, role CRUD, and role reassignment for team members. ### Fixed diff --git a/FacturapiTest/WrapperBehaviorTests.cs b/FacturapiTest/WrapperBehaviorTests.cs index 1725a7d..29021dc 100644 --- a/FacturapiTest/WrapperBehaviorTests.cs +++ b/FacturapiTest/WrapperBehaviorTests.cs @@ -71,6 +71,129 @@ public async Task OrganizationDeleteSeriesAsync_UsesDeleteSeriesRoute() Assert.Equal("A", result.Name); } + [Fact] + public async Task OrganizationListTeamAccessAsync_UsesTeamRoute() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal("/v2/organizations/org_1/team", request.RequestUri.PathAndQuery); + return Task.FromResult(JsonResponse("[]")); + }); + + var wrapper = new OrganizationWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.ListTeamAccessAsync("org_1"); + + Assert.NotNull(result); + } + + [Fact] + public async Task OrganizationInviteUserToTeamAsync_UsesInvitesRoute() + { + var handler = new RecordingHandler(async (request, cancellationToken) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal("/v2/organizations/org_1/team/invites", request.RequestUri.PathAndQuery); + Assert.NotNull(request.Content); + var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("\"email\":\"dev@example.com\"", body); + + return JsonResponse("{\"id\":\"inv_001\",\"email\":\"dev@example.com\"}"); + }); + + var wrapper = new OrganizationWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.InviteUserToTeamAsync("org_1", new Dictionary + { + ["email"] = "dev@example.com" + }); + + Assert.Equal("inv_001", result.Id); + } + + [Fact] + public async Task OrganizationListTeamRoleOperationsAsync_UsesOperationsRoute() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + Assert.Equal(HttpMethod.Get, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal("/v2/organizations/org_1/team/roles/operations", request.RequestUri.PathAndQuery); + return Task.FromResult(JsonResponse("[\"invoice:list\"]")); + }); + + var wrapper = new OrganizationWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.ListTeamRoleOperationsAsync("org_1"); + + Assert.Single(result); + Assert.Equal("invoice:list", result[0]); + } + + [Fact] + public async Task OrganizationRemoveTeamAccessAsync_ParsesOkResponse() + { + var handler = new RecordingHandler((request, cancellationToken) => + { + Assert.Equal(HttpMethod.Delete, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal("/v2/organizations/org_1/team/acc_1", request.RequestUri.PathAndQuery); + return Task.FromResult(JsonResponse("{\"ok\":true}")); + }); + + var wrapper = new OrganizationWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.RemoveTeamAccessAsync("org_1", "acc_1"); + + Assert.True(result); + } + + [Fact] + public async Task OrganizationRespondTeamInviteAsync_UsesInviteResponseRoute() + { + var handler = new RecordingHandler(async (request, cancellationToken) => + { + Assert.Equal(HttpMethod.Post, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal("/v2/organizations/invites/inv_1/response", request.RequestUri.PathAndQuery); + Assert.NotNull(request.Content); + var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("\"accept\":true", body); + return JsonResponse("{\"ok\":true}"); + }); + + var wrapper = new OrganizationWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.RespondTeamInviteAsync("inv_1", new Dictionary + { + ["accept"] = true + }); + + Assert.True(result); + } + + [Fact] + public async Task OrganizationUpdateTeamRoleAsync_UsesRoleRoute() + { + var handler = new RecordingHandler(async (request, cancellationToken) => + { + Assert.Equal(HttpMethod.Put, request.Method); + Assert.NotNull(request.RequestUri); + Assert.Equal("/v2/organizations/org_1/team/roles/role_1", request.RequestUri.PathAndQuery); + Assert.NotNull(request.Content); + var body = await request.Content.ReadAsStringAsync().ConfigureAwait(false); + Assert.Contains("\"name\":\"Senior billing analyst\"", body); + return JsonResponse("{\"id\":\"role_1\",\"name\":\"Senior billing analyst\"}"); + }); + + var wrapper = new OrganizationWrapper("test_key", "v2", CreateHttpClient(handler)); + var result = await wrapper.UpdateTeamRoleAsync("org_1", "role_1", new Dictionary + { + ["name"] = "Senior billing analyst" + }); + + Assert.Equal("role_1", result.Id); + Assert.Equal("Senior billing analyst", result.Name); + } + [Fact] public async Task RetentionListAsync_UsesRetentionsRoute() { diff --git a/Models/OrganizationInvite.cs b/Models/OrganizationInvite.cs new file mode 100644 index 0000000..aebd23d --- /dev/null +++ b/Models/OrganizationInvite.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace Facturapi +{ + public class OrganizationInvite + { + public string Id { get; set; } + public DateTime? CreatedAt { get; set; } + public string Email { get; set; } + public string OrganizationName { get; set; } + public string Role { get; set; } + public string RoleName { get; set; } + public List Roles { get; set; } + public DateTime? ExpiresAt { get; set; } + } +} diff --git a/Models/OrganizationTeamRole.cs b/Models/OrganizationTeamRole.cs new file mode 100644 index 0000000..1f82574 --- /dev/null +++ b/Models/OrganizationTeamRole.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; + +namespace Facturapi +{ + public class OrganizationTeamRole + { + public string Id { get; set; } + public string Name { get; set; } + public string TemplateCode { get; set; } + public string Scope { get; set; } + public string Organization { get; set; } + public List Operations { get; set; } + public int UsedBy { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + public Dictionary CreatedBy { get; set; } + public Dictionary UpdatedBy { get; set; } + } +} diff --git a/Models/OrganizationTeamRoleTemplate.cs b/Models/OrganizationTeamRoleTemplate.cs new file mode 100644 index 0000000..3b5a662 --- /dev/null +++ b/Models/OrganizationTeamRoleTemplate.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Facturapi +{ + public class OrganizationTeamRoleTemplate + { + public string Code { get; set; } + public string Name { get; set; } + public string Description { get; set; } + public List Operations { get; set; } + } +} diff --git a/Models/OrganizationUserAccess.cs b/Models/OrganizationUserAccess.cs new file mode 100644 index 0000000..539de74 --- /dev/null +++ b/Models/OrganizationUserAccess.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; + +namespace Facturapi +{ + public class OrganizationUserAccess + { + public string Id { get; set; } + public string FullName { get; set; } + public string Email { get; set; } + public string Role { get; set; } + public string RoleName { get; set; } + public string Organization { get; set; } + public List Operations { get; set; } + public DateTime? CreatedAt { get; set; } + public DateTime? UpdatedAt { get; set; } + } +} diff --git a/Router/OrganizationRouter.cs b/Router/OrganizationRouter.cs index af45881..2f7b533 100644 --- a/Router/OrganizationRouter.cs +++ b/Router/OrganizationRouter.cs @@ -120,5 +120,60 @@ public static string UpdateDomain(string organizationId) { return $"{RetrieveOrganization(organizationId)}/domain"; } + + public static string ListTeamAccess(string organizationId) + { + return $"{RetrieveOrganization(organizationId)}/team"; + } + + public static string RetrieveTeamAccess(string organizationId, string accessId) + { + return $"{ListTeamAccess(organizationId)}/{accessId}"; + } + + public static string UpdateTeamAccessRole(string organizationId, string accessId) + { + return $"{RetrieveTeamAccess(organizationId, accessId)}/role"; + } + + public static string ListSentTeamInvites(string organizationId) + { + return $"{ListTeamAccess(organizationId)}/invites"; + } + + public static string CancelTeamInvite(string organizationId, string inviteKey) + { + return $"{ListSentTeamInvites(organizationId)}/{inviteKey}"; + } + + public static string ListReceivedTeamInvites() + { + return "organizations/invites/pending"; + } + + public static string RespondTeamInvite(string inviteKey) + { + return $"organizations/invites/{inviteKey}/response"; + } + + public static string ListTeamRoles(string organizationId) + { + return $"{ListTeamAccess(organizationId)}/roles"; + } + + public static string ListTeamRoleTemplates(string organizationId) + { + return $"{ListTeamRoles(organizationId)}/templates"; + } + + public static string ListTeamRoleOperations(string organizationId) + { + return $"{ListTeamRoles(organizationId)}/operations"; + } + + public static string RetrieveTeamRole(string organizationId, string roleId) + { + return $"{ListTeamRoles(organizationId)}/{roleId}"; + } } } diff --git a/Wrappers/IOrganizationWrapper.cs b/Wrappers/IOrganizationWrapper.cs index 176ae51..b3a9705 100644 --- a/Wrappers/IOrganizationWrapper.cs +++ b/Wrappers/IOrganizationWrapper.cs @@ -30,5 +30,21 @@ public interface IOrganizationWrapper Task DeleteSeriesAsync(string id, string seriesName, CancellationToken cancellationToken = default); Task> DeleteLiveApiKeyAsync(string id, string apiKeyId, CancellationToken cancellationToken = default); Task UpdateSelfInvoiceSettingsAsync(string organizationId, Dictionary data, CancellationToken cancellationToken = default); + Task> ListTeamAccessAsync(string organizationId, CancellationToken cancellationToken = default); + Task RetrieveTeamAccessAsync(string organizationId, string accessId, CancellationToken cancellationToken = default); + Task UpdateTeamAccessRoleAsync(string organizationId, string accessId, string role, CancellationToken cancellationToken = default); + Task RemoveTeamAccessAsync(string organizationId, string accessId, CancellationToken cancellationToken = default); + Task> ListSentTeamInvitesAsync(string organizationId, CancellationToken cancellationToken = default); + Task InviteUserToTeamAsync(string organizationId, Dictionary data, CancellationToken cancellationToken = default); + Task CancelTeamInviteAsync(string organizationId, string inviteKey, CancellationToken cancellationToken = default); + Task> ListReceivedTeamInvitesAsync(CancellationToken cancellationToken = default); + Task RespondTeamInviteAsync(string inviteKey, Dictionary data, CancellationToken cancellationToken = default); + Task> ListTeamRolesAsync(string organizationId, CancellationToken cancellationToken = default); + Task> ListTeamRoleTemplatesAsync(string organizationId, CancellationToken cancellationToken = default); + Task> ListTeamRoleOperationsAsync(string organizationId, CancellationToken cancellationToken = default); + Task RetrieveTeamRoleAsync(string organizationId, string roleId, CancellationToken cancellationToken = default); + Task CreateTeamRoleAsync(string organizationId, Dictionary data, CancellationToken cancellationToken = default); + Task UpdateTeamRoleAsync(string organizationId, string roleId, Dictionary data, CancellationToken cancellationToken = default); + Task DeleteTeamRoleAsync(string organizationId, string roleId, CancellationToken cancellationToken = default); } } diff --git a/Wrappers/OrganizationWrapper.cs b/Wrappers/OrganizationWrapper.cs index e1515f3..83ac9c8 100644 --- a/Wrappers/OrganizationWrapper.cs +++ b/Wrappers/OrganizationWrapper.cs @@ -288,5 +288,188 @@ public async Task UpdateSelfInvoiceSettingsAsync(string organizati return organization; } } + + public async Task> ListTeamAccessAsync(string organizationId, CancellationToken cancellationToken = default) + { + using (var response = await client.GetAsync(Router.ListTeamAccess(organizationId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject>(resultString, this.jsonSettings); + } + } + + public async Task RetrieveTeamAccessAsync(string organizationId, string accessId, CancellationToken cancellationToken = default) + { + using (var response = await client.GetAsync(Router.RetrieveTeamAccess(organizationId, accessId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(resultString, this.jsonSettings); + } + } + + public async Task UpdateTeamAccessRoleAsync(string organizationId, string accessId, string role, CancellationToken cancellationToken = default) + { + using (var content = new StringContent(JsonConvert.SerializeObject(new Dictionary { ["role"] = role }), Encoding.UTF8, "application/json")) + using (var response = await client.PutAsync(Router.UpdateTeamAccessRole(organizationId, accessId), content, cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(resultString, this.jsonSettings); + } + } + + public async Task RemoveTeamAccessAsync(string organizationId, string accessId, CancellationToken cancellationToken = default) + { + using (var response = await client.DeleteAsync(Router.RetrieveTeamAccess(organizationId, accessId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return DeserializeOk(resultString); + } + } + + public async Task> ListSentTeamInvitesAsync(string organizationId, CancellationToken cancellationToken = default) + { + using (var response = await client.GetAsync(Router.ListSentTeamInvites(organizationId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject>(resultString, this.jsonSettings); + } + } + + public async Task InviteUserToTeamAsync(string organizationId, Dictionary data, CancellationToken cancellationToken = default) + { + using (var content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json")) + using (var response = await client.PostAsync(Router.ListSentTeamInvites(organizationId), content, cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(resultString, this.jsonSettings); + } + } + + public async Task CancelTeamInviteAsync(string organizationId, string inviteKey, CancellationToken cancellationToken = default) + { + using (var response = await client.DeleteAsync(Router.CancelTeamInvite(organizationId, inviteKey), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return DeserializeOk(resultString); + } + } + + public async Task> ListReceivedTeamInvitesAsync(CancellationToken cancellationToken = default) + { + using (var response = await client.GetAsync(Router.ListReceivedTeamInvites(), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject>(resultString, this.jsonSettings); + } + } + + public async Task RespondTeamInviteAsync(string inviteKey, Dictionary data, CancellationToken cancellationToken = default) + { + using (var content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json")) + using (var response = await client.PostAsync(Router.RespondTeamInvite(inviteKey), content, cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return DeserializeOk(resultString); + } + } + + public async Task> ListTeamRolesAsync(string organizationId, CancellationToken cancellationToken = default) + { + using (var response = await client.GetAsync(Router.ListTeamRoles(organizationId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject>(resultString, this.jsonSettings); + } + } + + public async Task> ListTeamRoleTemplatesAsync(string organizationId, CancellationToken cancellationToken = default) + { + using (var response = await client.GetAsync(Router.ListTeamRoleTemplates(organizationId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject>(resultString, this.jsonSettings); + } + } + + public async Task> ListTeamRoleOperationsAsync(string organizationId, CancellationToken cancellationToken = default) + { + using (var response = await client.GetAsync(Router.ListTeamRoleOperations(organizationId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject>(resultString, this.jsonSettings); + } + } + + public async Task RetrieveTeamRoleAsync(string organizationId, string roleId, CancellationToken cancellationToken = default) + { + using (var response = await client.GetAsync(Router.RetrieveTeamRole(organizationId, roleId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(resultString, this.jsonSettings); + } + } + + public async Task CreateTeamRoleAsync(string organizationId, Dictionary data, CancellationToken cancellationToken = default) + { + using (var content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json")) + using (var response = await client.PostAsync(Router.ListTeamRoles(organizationId), content, cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(resultString, this.jsonSettings); + } + } + + public async Task UpdateTeamRoleAsync(string organizationId, string roleId, Dictionary data, CancellationToken cancellationToken = default) + { + using (var content = new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json")) + using (var response = await client.PutAsync(Router.RetrieveTeamRole(organizationId, roleId), content, cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return JsonConvert.DeserializeObject(resultString, this.jsonSettings); + } + } + + public async Task DeleteTeamRoleAsync(string organizationId, string roleId, CancellationToken cancellationToken = default) + { + using (var response = await client.DeleteAsync(Router.RetrieveTeamRole(organizationId, roleId), cancellationToken).ConfigureAwait(false)) + { + await this.ThrowIfErrorAsync(response, cancellationToken).ConfigureAwait(false); + var resultString = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return DeserializeOk(resultString); + } + } + + private bool DeserializeOk(string resultString) + { + var result = JsonConvert.DeserializeObject>(resultString, this.jsonSettings); + if (result != null && result.TryGetValue("ok", out var okValue)) + { + if (okValue is bool okBool) + { + return okBool; + } + if (bool.TryParse(okValue.ToString(), out var parsed)) + { + return parsed; + } + } + + return false; + } } }