diff --git a/cloud/src/Signal.Api.Common/Contact/EntityContactSetDto.cs b/cloud/src/Signal.Api.Common/Contact/EntityContactSetDto.cs new file mode 100644 index 0000000000..ddea73d8ce --- /dev/null +++ b/cloud/src/Signal.Api.Common/Contact/EntityContactSetDto.cs @@ -0,0 +1,15 @@ +using System; +using System.Text.Json.Serialization; + +namespace Signal.Api.Common.Contact; + +[Serializable] +public class EntityContactSetDto +{ + + [JsonPropertyName("valueSerialized")] + public string? ValueSerialized { get; set; } + + [JsonPropertyName("timeStamp")] + public DateTime? TimeStamp { get; set; } +} \ No newline at end of file diff --git a/cloud/src/Signal.Api.Common/Exceptions/HttpRequestExtensions.cs b/cloud/src/Signal.Api.Common/Exceptions/HttpRequestExtensions.cs index 06c0d9b0ea..221547a0d6 100644 --- a/cloud/src/Signal.Api.Common/Exceptions/HttpRequestExtensions.cs +++ b/cloud/src/Signal.Api.Common/Exceptions/HttpRequestExtensions.cs @@ -99,22 +99,16 @@ await req.JsonResponseAsync( ex.Code, cancellationToken: cancellationToken); - public static async Task UserRequest( - this HttpRequestData req, - CancellationToken cancellationToken, - IFunctionAuthenticator authenticator, - Func> executionBody) - { - try - { - var user = await authenticator.AuthenticateAsync(req, cancellationToken); - return await req.JsonResponseAsync(await executionBody(new UserRequestContext(user, cancellationToken)), cancellationToken: cancellationToken); - } - catch (ExpectedHttpException ex) - { - return await ExceptionResponseAsync(req, ex, cancellationToken); - } - } + //public static Task UserRequest( + // this HttpRequestData req, + // CancellationToken cancellationToken, + // IFunctionAuthenticator authenticator, + // Func executionBody) => + // UserRequest(req, authenticator, async context => + // { + // await executionBody(context); + // return req.CreateResponse(HttpStatusCode.OK); + // }, cancellationToken); public static Task UserRequest( this HttpRequestData req, @@ -177,6 +171,41 @@ private static async Task UserOrSystemRequest( } } + public static async Task UserRequest( + this HttpRequestData req, + CancellationToken cancellationToken, + IFunctionAuthenticator authenticator, + Func executionBody) + { + try + { + var user = await authenticator.AuthenticateAsync(req, cancellationToken); + await executionBody(new UserRequestContext(user, cancellationToken)); + return req.CreateResponse(HttpStatusCode.OK); + } + catch (ExpectedHttpException ex) + { + return await ExceptionResponseAsync(req, ex, cancellationToken); + } + } + + public static async Task UserRequest( + this HttpRequestData req, + CancellationToken cancellationToken, + IFunctionAuthenticator authenticator, + Func> executionBody) + { + try + { + var user = await authenticator.AuthenticateAsync(req, cancellationToken); + return await req.JsonResponseAsync(await executionBody(new UserRequestContext(user, cancellationToken)), cancellationToken: cancellationToken); + } + catch (ExpectedHttpException ex) + { + return await ExceptionResponseAsync(req, ex, cancellationToken); + } + } + private static async Task UserRequest( this HttpRequestData req, IFunctionAuthenticator authenticator, diff --git a/cloud/src/Signal.Core/Entities/EntityService.cs b/cloud/src/Signal.Core/Entities/EntityService.cs index 2f6316762d..42a3e4eea4 100644 --- a/cloud/src/Signal.Core/Entities/EntityService.cs +++ b/cloud/src/Signal.Core/Entities/EntityService.cs @@ -14,27 +14,24 @@ namespace Signal.Core.Entities; -internal class EntityService : IEntityService +internal class EntityService( + ISharingService sharingService, + IAzureStorageDao storageDao, + IAzureStorage storage, + IProcessManager processManager, + ISignalRService signalRService) : IEntityService { - private readonly ISharingService sharingService; - private readonly IAzureStorageDao storageDao; - private readonly IAzureStorage storage; - private readonly IProcessManager processManager; - private readonly ISignalRService signalRService; - - public EntityService( - ISharingService sharingService, - IAzureStorageDao storageDao, - IAzureStorage storage, - IProcessManager processManager, - ISignalRService signalRService) - { - this.sharingService = sharingService ?? throw new ArgumentNullException(nameof(sharingService)); - this.storageDao = storageDao ?? throw new ArgumentNullException(nameof(storageDao)); - this.storage = storage ?? throw new ArgumentNullException(nameof(storage)); - this.processManager = processManager ?? throw new ArgumentNullException(nameof(processManager)); - this.signalRService = signalRService ?? throw new ArgumentNullException(nameof(signalRService)); - } + private readonly ISharingService sharingService = + sharingService ?? throw new ArgumentNullException(nameof(sharingService)); + + private readonly IAzureStorageDao storageDao = storageDao ?? throw new ArgumentNullException(nameof(storageDao)); + private readonly IAzureStorage storage = storage ?? throw new ArgumentNullException(nameof(storage)); + + private readonly IProcessManager processManager = + processManager ?? throw new ArgumentNullException(nameof(processManager)); + + private readonly ISignalRService signalRService = + signalRService ?? throw new ArgumentNullException(nameof(signalRService)); public async Task>> EntityUsersAsync( IEnumerable entityIds, @@ -45,18 +42,19 @@ await this.storageDao.AssignedUsersAsync( cancellationToken); public async Task> AllAsync( - string userId, - IEnumerable? types = null, + string userId, + IEnumerable? types = null, CancellationToken cancellationToken = default) => await this.storageDao.UserEntitiesAsync(userId, types, cancellationToken); public async Task> AllDetailedAsync( - string userId, - IEnumerable? types = null, + string userId, + IEnumerable? types = null, CancellationToken cancellationToken = default) => await this.storageDao.UserEntitiesDetailedAsync(userId, types, cancellationToken); - public async Task GetDetailedAsync(string userId, string entityId, CancellationToken cancellationToken = default) + public async Task GetDetailedAsync(string userId, string entityId, + CancellationToken cancellationToken = default) { var assignedTask = this.IsUserAssignedAsync(userId, entityId, cancellationToken); var getTask = this.storageDao.GetDetailedAsync(entityId, cancellationToken); @@ -65,10 +63,11 @@ public async Task> AllDetailedAsync( return !assignedTask.Result ? null : getTask.Result; } - public async Task GetInternalAsync(string entityId, CancellationToken cancellationToken = default) => + public async Task GetInternalAsync(string entityId, CancellationToken cancellationToken = default) => await this.storageDao.GetAsync(entityId, cancellationToken); - public async Task UpsertAsync(string userId, string? entityId, Func entityFunc, CancellationToken cancellationToken = default) + public async Task UpsertAsync(string userId, string? entityId, Func entityFunc, + CancellationToken cancellationToken = default) { // Check if existing entity was requested but not assigned var exists = false; @@ -100,18 +99,20 @@ await this.sharingService.AssignToUserAsync( return id; } - public async Task ContactAsync(IContactPointer pointer, CancellationToken cancellationToken = default) => + public async Task ContactAsync(IContactPointer pointer, CancellationToken cancellationToken = default) => await this.storageDao.ContactAsync(pointer, cancellationToken); - public async Task?> ContactsAsync(string entityId, CancellationToken cancellationToken = default) => + public async Task?> ContactsAsync(string entityId, + CancellationToken cancellationToken = default) => await this.storageDao.ContactsAsync(entityId, cancellationToken); public async Task> ContactsAsync( - IEnumerable pointers, + IEnumerable pointers, CancellationToken cancellationToken = default) => await Task.WhenAll(pointers.Select(p => this.ContactAsync(p, cancellationToken))); - public async Task?> ContactsAsync(IEnumerable entityIds, CancellationToken cancellationToken = default) => + public async Task?> ContactsAsync(IEnumerable entityIds, + CancellationToken cancellationToken = default) => await this.storageDao.ContactsAsync(entityIds, cancellationToken); public async Task ContactSetMetadataAsync( @@ -202,7 +203,7 @@ await Task.WhenAll( // Processing var queueStateProcessingTask = Task.CompletedTask; - if (!doNotProcess) + if (!doNotProcess) queueStateProcessingTask = this.processManager.AddAsync(pointer, cancellationToken); // Caching @@ -220,6 +221,23 @@ await Task.WhenAll( } } + public async Task ContactDeleteAsync( + string userId, + IContactPointer pointer, + CancellationToken cancellationToken = default) + { + // Validate assignment + if (!(await this.IsUserAssignedAsync(userId, pointer.EntityId, cancellationToken))) + throw new ExpectedHttpException(HttpStatusCode.NotFound); + + // TODO: Check if user is owner + + // TODO: Delete contact history + + // Delete contact + await this.storage.RemoveAsync(pointer, cancellationToken); + } + private async Task CacheEntityAsync(IContactPointer pointer, CancellationToken cancellationToken = default) { // NOTE: Implementation checks whether given contact is appropriate to be cached @@ -241,15 +259,34 @@ public async Task RemoveAsync(string userId, string entityId, CancellationToken throw new ExpectedHttpException(HttpStatusCode.NotFound); // TODO: Check if user is owner (only owner can delete entity) - // TODO: Remove assignments for all users (since entity doesn't exist anymore) - // TODO: Remove all contact history - // TODO: Remove all contact + + // Remove assignments for all users (since entity doesn't exist anymore) + var entityUsers = (await this.storageDao.AssignedUsersAsync(new[] {entityId}, cancellationToken))[entityId]; + var assignmentsDeleteTask = Task.WhenAll(entityUsers.Select(u => + this.sharingService.UnAssignFromUserAsync(u, entityId, cancellationToken))); + + // Remove all contacts + Task? contactsDeleteTask = null; + var contacts = await this.storageDao.ContactsAsync(entityId, cancellationToken); + if (contacts != null) + { + contactsDeleteTask = Task.WhenAll(contacts.Select(c => + this.ContactDeleteAsync( + userId, + new ContactPointer(entityId, c.ChannelName, c.ContactName), + cancellationToken))); + } + + // Wait for all to finish + await Task.WhenAll( + contactsDeleteTask ?? Task.CompletedTask, + assignmentsDeleteTask); // Remove entity await this.storage.RemoveEntityAsync(entityId, cancellationToken); } - public Task IsUserAssignedAsync(string userId, string id, CancellationToken cancellationToken = default) => + public Task IsUserAssignedAsync(string userId, string id, CancellationToken cancellationToken = default) => this.storageDao.IsUserAssignedAsync(userId, id, cancellationToken); public async Task BroadcastToEntityUsersAsync( @@ -259,7 +296,7 @@ public async Task BroadcastToEntityUsersAsync( object[] arguments, CancellationToken cancellationToken = default) { - var entityUsers = await this.EntityUsersAsync(new[] { entityId }, cancellationToken); + var entityUsers = await this.EntityUsersAsync(new[] {entityId}, cancellationToken); await this.signalRService.SendToUsersAsync( entityUsers[entityId].ToList(), hubName, diff --git a/cloud/src/Signal.Core/Entities/IEntityService.cs b/cloud/src/Signal.Core/Entities/IEntityService.cs index 1fca84b1aa..977a804732 100644 --- a/cloud/src/Signal.Core/Entities/IEntityService.cs +++ b/cloud/src/Signal.Core/Entities/IEntityService.cs @@ -66,4 +66,9 @@ Task ContactSetMetadataAsync( IContactPointer pointer, string? metadata, CancellationToken cancellationToken = default); + + Task ContactDeleteAsync( + string userId, + IContactPointer pointer, + CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/cloud/src/Signal.Core/Sharing/SharingService.cs b/cloud/src/Signal.Core/Sharing/SharingService.cs index 9592170189..6c888960ec 100644 --- a/cloud/src/Signal.Core/Sharing/SharingService.cs +++ b/cloud/src/Signal.Core/Sharing/SharingService.cs @@ -7,21 +7,14 @@ namespace Signal.Core.Sharing; -public class SharingService : ISharingService -{ - private readonly IAzureStorage azureStorage; - private readonly IUserService userService; - private readonly ILogger logger; - - public SharingService( - IAzureStorage azureStorage, +public class SharingService(IAzureStorage azureStorage, IUserService userService, ILogger logger) - { - this.azureStorage = azureStorage ?? throw new ArgumentNullException(nameof(azureStorage)); - this.userService = userService ?? throw new ArgumentNullException(nameof(userService)); - this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - } + : ISharingService +{ + private readonly IAzureStorage azureStorage = azureStorage; + private readonly IUserService userService = userService; + private readonly ILogger logger = logger; public async Task AssignToUserEmailAsync( string userEmail, diff --git a/cloud/src/Signal.Core/Storage/IAzureStorage.cs b/cloud/src/Signal.Core/Storage/IAzureStorage.cs index 8b85c31dc7..2f9b90b5b9 100644 --- a/cloud/src/Signal.Core/Storage/IAzureStorage.cs +++ b/cloud/src/Signal.Core/Storage/IAzureStorage.cs @@ -33,6 +33,7 @@ public interface IAzureStorage Task QueueAsync(ContactStateProcessQueueItem item, CancellationToken cancellationToken = default); Task QueueAsync(UsageQueueItem? item, CancellationToken cancellationToken = default); Task UpsertAsync(IContactLinkProcessTriggerItem cacheItem, CancellationToken cancellationToken = default); + Task RemoveAsync(IContactPointer contactPointer, CancellationToken cancellationToken); Task RemoveAsync(IUserAssignedEntity assignment, CancellationToken cancellationToken = default); Task EnsureTableAsync(string tableName, CancellationToken cancellationToken = default); Task EnsureQueueAsync(string queueName, CancellationToken cancellationToken = default); diff --git a/cloud/src/Signal.Infrastructure.AzureStorage.Tables/AzureContact.cs b/cloud/src/Signal.Infrastructure.AzureStorage.Tables/AzureContact.cs index d7f0ed7cda..6b728d66ce 100644 --- a/cloud/src/Signal.Infrastructure.AzureStorage.Tables/AzureContact.cs +++ b/cloud/src/Signal.Infrastructure.AzureStorage.Tables/AzureContact.cs @@ -17,7 +17,7 @@ internal class AzureContact : AzureTableEntityBase public AzureContact() : base(string.Empty, string.Empty) { } - + protected AzureContact(string partitionKey, string rowKey) : base(partitionKey, rowKey) { } @@ -33,9 +33,13 @@ public static AzureContact From(IContact contact) }; } + public static (string partitionKey, string rowKey) ToStorageIdentifier(IContactPointer contactPointer) => + (contactPointer.EntityId, $"{contactPointer.ChannelName}-{contactPointer.ContactName}"); + public static AzureContact From(IContactPointer contactPointer) { - return new AzureContact(contactPointer.EntityId, $"{contactPointer.ChannelName}-{contactPointer.ContactName}") + var (partitionKey, rowKey) = ToStorageIdentifier(contactPointer); + return new AzureContact(partitionKey, rowKey) { Name = contactPointer.ContactName, TimeStamp = DateTime.UtcNow diff --git a/cloud/src/Signal.Infrastructure.AzureStorage.Tables/AzureStorage.cs b/cloud/src/Signal.Infrastructure.AzureStorage.Tables/AzureStorage.cs index dc499f2be7..b5d935055a 100644 --- a/cloud/src/Signal.Infrastructure.AzureStorage.Tables/AzureStorage.cs +++ b/cloud/src/Signal.Infrastructure.AzureStorage.Tables/AzureStorage.cs @@ -99,6 +99,14 @@ await this.WithQueueClientAsync( cancellationToken: cancellationToken), cancellationToken); } + public Task RemoveAsync(IContactPointer contactPointer, CancellationToken cancellationToken) + { + var (partitionKey, rowKey) = AzureContact.ToStorageIdentifier(contactPointer); + return this.WithClientAsync( + ItemTableNames.Contacts, + c => c.DeleteEntityAsync(partitionKey, rowKey, cancellationToken: cancellationToken), cancellationToken); + } + public async Task RemoveAsync(IUserAssignedEntity assignment, CancellationToken cancellationToken = default) => await this.WithClientAsync( ItemTableNames.UserAssignedEntity, diff --git a/cloud/src/Signalco.Api.Public/Functions/Contacts/ContactDeleteFunction.cs b/cloud/src/Signalco.Api.Public/Functions/Contacts/ContactDeleteFunction.cs new file mode 100644 index 0000000000..19d3c6a729 --- /dev/null +++ b/cloud/src/Signalco.Api.Public/Functions/Contacts/ContactDeleteFunction.cs @@ -0,0 +1,56 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Signal.Api.Common.Auth; +using Signal.Api.Common.Exceptions; +using Signal.Api.Common.OpenApi; +using Signal.Core.Contacts; +using Signal.Core.Entities; +using Signal.Core.Exceptions; + +namespace Signalco.Api.Public.Functions.Contacts; + +public class ContactDeleteFunction +{ + private readonly IFunctionAuthenticator functionAuthenticator; + private readonly IEntityService entityService; + + public ContactDeleteFunction( + IFunctionAuthenticator functionAuthenticator, + IEntityService entityService) + { + this.functionAuthenticator = functionAuthenticator ?? throw new ArgumentNullException(nameof(functionAuthenticator)); + this.entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); + } + + [Function("Entity-Contact-Delete")] + [OpenApiSecurityAuth0Token] + [OpenApiOperation("Contact", Description = "Deletes the contact.")] + [OpenApiResponseWithoutBody] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "entity/{id:guid}/contacts/{channelName}/{contactName}")] + HttpRequestData req, + string id, + string channelName, + string contactName, + CancellationToken cancellationToken = default) => + await req.UserRequest(cancellationToken, this.functionAuthenticator, async context => + { + if (string.IsNullOrWhiteSpace(id) || + string.IsNullOrWhiteSpace(channelName) || + string.IsNullOrWhiteSpace(contactName)) + throw new ExpectedHttpException( + HttpStatusCode.BadRequest, + "EntityId, ChannelName and ContactName parameters are required."); + + var contactPointer = new ContactPointer( + id ?? throw new ArgumentException("Contact pointer requires entity identifier"), + channelName ?? throw new ArgumentException("Contact pointer requires channel name"), + contactName ?? throw new ArgumentException("Contact pointer requires contact name")); + + await this.entityService.ContactDeleteAsync(context.User.UserId, contactPointer, cancellationToken); + }); +} \ No newline at end of file diff --git a/cloud/src/Signalco.Api.Public/Functions/Contacts/ContactSetFunction.cs b/cloud/src/Signalco.Api.Public/Functions/Contacts/ContactSetFunction.cs index 94b4ee7c20..68014eafb4 100644 --- a/cloud/src/Signalco.Api.Public/Functions/Contacts/ContactSetFunction.cs +++ b/cloud/src/Signalco.Api.Public/Functions/Contacts/ContactSetFunction.cs @@ -59,13 +59,55 @@ await req.UserRequest(cancellationToken, this.functionAuthenticat payload.ChannelName ?? throw new ArgumentException("Contact pointer requires channel name"), payload.ContactName ?? throw new ArgumentException("Contact pointer requires contact name")); - var contactSetTask = this.entityService.ContactSetAsync(contactPointer, payload.ValueSerialized, payload.TimeStamp, cancellationToken); + await this.ContactSetAsync(context.User.UserId, contactPointer, payload.ValueSerialized, payload.TimeStamp, cancellationToken); + }); - // TODO: Move to UsageService - var usageTask = this.storage.QueueAsync(new UsageQueueItem(context.User.UserId, UsageKind.ContactSet), cancellationToken); + [Function("Entity-Contact-Set")] + [OpenApiSecurityAuth0Token] + [Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Attributes.OpenApiOperation("EntityContactSet", "Contact", Description = "Sets contact value.")] + [OpenApiJsonRequestBody] + [OpenApiResponseWithoutBody] + [OpenApiResponseBadRequestValidation] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "entity/{id:guid}/contacts/{channelName}/{contactName}")] + HttpRequestData req, + string id, + string channelName, + string contactName, + CancellationToken cancellationToken = default) => + await req.UserRequest(cancellationToken, this.functionAuthenticator, async context => + { + if (string.IsNullOrWhiteSpace(channelName) || + string.IsNullOrWhiteSpace(contactName) || + string.IsNullOrWhiteSpace(id)) + throw new ExpectedHttpException( + HttpStatusCode.BadRequest, + "EntityId, ChannelName and ContactName parameters are required."); + + await context.ValidateUserAssignedAsync(this.entityService, id); - await Task.WhenAll( - contactSetTask, - usageTask); + var contactPointer = new ContactPointer( + id ?? throw new ArgumentException("Contact pointer requires entity identifier"), + channelName ?? throw new ArgumentException("Contact pointer requires channel name"), + contactName ?? throw new ArgumentException("Contact pointer requires contact name")); + + await this.ContactSetAsync(context.User.UserId, contactPointer, context.Payload.ValueSerialized, context.Payload.TimeStamp, cancellationToken); }); + + private async Task ContactSetAsync( + string userId, + IContactPointer contactPointer, + string? valueSerialized, + DateTime? timeStamp, + CancellationToken cancellationToken = default) + { + var contactSetTask = this.entityService.ContactSetAsync(contactPointer, valueSerialized, timeStamp, cancellationToken); + + // TODO: Move to UsageService + var usageTask = this.storage.QueueAsync(new UsageQueueItem(userId, UsageKind.ContactSet), cancellationToken); + + await Task.WhenAll( + contactSetTask, + usageTask); + } } \ No newline at end of file diff --git a/cloud/src/Signalco.Api.Public/Functions/Entity/EntityDeleteFunction.cs b/cloud/src/Signalco.Api.Public/Functions/Entity/EntityDeleteFunction.cs index c20bf89335..d507666a4c 100644 --- a/cloud/src/Signalco.Api.Public/Functions/Entity/EntityDeleteFunction.cs +++ b/cloud/src/Signalco.Api.Public/Functions/Entity/EntityDeleteFunction.cs @@ -30,8 +30,8 @@ public EntityDeleteFunction( [OpenApiSecurityAuth0Token] [OpenApiOperation("Entity", Description = "Deletes the entity.")] [OpenApiJsonRequestBody(Description = "Information about entity to delete.")] - [OpenApiResponseWithoutBody(HttpStatusCode.OK)] [OpenApiResponseBadRequestValidation] + [OpenApiResponseWithoutBody] [OpenApiResponseWithoutBody(HttpStatusCode.NotFound)] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "entity")]