Skip to content

Commit

Permalink
Consumer API: Terminate a Relationship (#588)
Browse files Browse the repository at this point in the history
* chore: move entities to Aggregates folder

* feat: creation of Relationship and remove Changes related stuff

* test: split into two tests

* feat: acceptance of creation

* feat: reject creation

* feat: revoke creation

* test: split relationship tests into multiple files

* feat: allow multiple relationships as long as there is only one active

* chore: remove redundant parameter

* refactor: make RelationshipTemplatesRepository.Find return null instead of throwing

* feat: add Handler

* feat: add and use expressions

* chore: don't use AutoMapper and add more tests

* feat: reject relationship

* feat: AcceptRelationshipCommand

* feat: RevokeRelationshipCommand

* feat: add AuditLog to DTOs

* feat: add CreationContent property to RelationshipDTO

* feat: add additional properties to RelationshipCreatedIntegrationEvent and RelationshipStatusChangedIntegrationEvent

* feat: handle new integration events in Synchronization module

* chore: formatting

* test: fix tests

* feat: replace integration events in quotas module with new ones

* feat: add migration

* feat: add controller methods

* chore: fix/ignore compiler warnings

* refactor: cleanup error codes

* feat: add insomnia workspace

* feat: add openapi.yml

* fix: add RelationshipMetadataDTO type and add creationContent property to RelationshipDTO

* refactor: rename Content to CreationContent in request to create a relationship

* chore: update InsomniaWorkspace and openapi.yml

* feat: implement domain part of relationship termination

* feat: implement application part of relationship termination

* feat: implement relationship termination controller

* feat: disable creating new relationship while terminated one exists

* feat: disable sending messages when relationship is terminated

* test: add relationship termination domain and handler tests

* chore: rename RelationshipStatus "Accepted" to "Active"

* feat: trigger external event

* chore: fix formatting

* chore: fix merge conflicts

* chore: remove redundant whitespace

* fix: add missing directive

* fix: update Content to CreationContent

* chore: update files prior to making PR ready for review

* chore: fix formatting

* feat: add AcceptanceContent

* fix: avoid error on creation of RelationshipsOverview view when RelationshipChanges table does not exist

* feat: (WIP!!): update Admin API RelationshipOverviews view

* feat: add AcceptanceContent to DTO

* fix: pass AcceptanceContent to AcceptRelationshipCommand

* chore: use postgres in Admin CLI launchSettings.json

* feat: implement PR change requests

* feat: implement PR change requests

* chore: fix formatting

* feat: implement PR change requests

* fix: relationships overview migration

* feat: update revamped relationships overview view with audit log

* test: add relationship tests

* chore: fix formatting and remove redundant overload

* chore: fix formatting

* feat: implement PR change requests

* feat: implement PR change requests

* chore: remove redundant Relationship statuses

* fix: update condition

* fix: re-introduce Terminated status

* chore: remove unused method

* refactor: combine checks for other relationships

---------

Co-authored-by: Timo Notheisen <[email protected]>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Co-authored-by: Timo Notheisen <[email protected]>
Co-authored-by: Daniel Almeida <[email protected]>
  • Loading branch information
5 people authored Apr 17, 2024
1 parent 8c4f151 commit bb3ccbb
Show file tree
Hide file tree
Showing 23 changed files with 450 additions and 38 deletions.
11 changes: 3 additions & 8 deletions Modules/Messages/src/Messages.Application/ApplicationErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,8 @@ public static ApplicationError NoRelationshipToRecipientExists(string recipient
{
var recipientText = string.IsNullOrEmpty(recipient) ? "one of the recipients" : recipient;

return new ApplicationError("error.platform.validation.message.noRelationshipToRecipientExists", $"Cannot send message to {recipientText} because there is no relationship to it.");
}

public static ApplicationError MaxNumberOfUnreceivedMessagesReached(string recipient = "")
{
var recipientText = string.IsNullOrEmpty(recipient) ? "one of the recipient" : recipient;

return new ApplicationError("error.platform.validation.message.maxNumberOfUnreceivedMessagesReached", $"The message could not be sent because {recipientText} already has the maximum number of unread messages from you.");
return new ApplicationError(
"error.platform.validation.message.noRelationshipToRecipientExists",
$"Cannot send message to {recipientText} because there is no relationship to it.");
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
using Backbone.DevelopmentKit.Identity.ValueObjects;
using Backbone.Modules.Messages.Domain.Ids;
using Backbone.Modules.Messages.Domain.Entities;

namespace Backbone.Modules.Messages.Application.Infrastructure.Persistence.Repository;

public interface IRelationshipsRepository
{
Task<RelationshipId?> GetIdOfRelationshipBetweenSenderAndRecipient(IdentityAddress identityA, IdentityAddress identityB);
Task<Relationship?> FindRelationship(IdentityAddress identityA, IdentityAddress identityB, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,26 +67,21 @@ private async Task<List<RecipientInformation>> ValidateRecipients(SendMessageCom

foreach (var recipientDto in request.Recipients)
{
var idOfRelationshipBetweenSenderAndRecipient = await _relationshipsRepository.GetIdOfRelationshipBetweenSenderAndRecipient(sender, recipientDto.Address);
var relationshipBetweenSenderAndRecipient = await _relationshipsRepository.FindRelationship(sender, recipientDto.Address, cancellationToken);

if (idOfRelationshipBetweenSenderAndRecipient == null)
if (relationshipBetweenSenderAndRecipient == null)
{
_logger.LogInformation("Sending message aborted. There is no relationship between sender ({sender}) and recipient ({recipient}).", sender, recipientDto.Address);
throw new OperationFailedException(ApplicationErrors.NoRelationshipToRecipientExists(recipientDto.Address));
}

var numberOfUnreceivedMessagesFromActiveIdentity = await _messagesRepository.CountUnreceivedMessagesFromSenderToRecipient(sender, recipientDto.Address, cancellationToken);

if (numberOfUnreceivedMessagesFromActiveIdentity >= _options.MaxNumberOfUnreceivedMessagesFromOneSender)
{
_logger.LogInformation(
"Sending message aborted. Recipient '{recipient}' already has '{numberOfUnreceivedMessagesFromActiveIdentity}' unreceived messages from sender '{sender}', which is more than the maximum ({maxNumberOfUnreceivedMessagesFromOneSender}).",
recipientDto.Address, numberOfUnreceivedMessagesFromActiveIdentity, sender, _options.MaxNumberOfUnreceivedMessagesFromOneSender);

throw new OperationFailedException(ApplicationErrors.MaxNumberOfUnreceivedMessagesReached(recipientDto.Address));
}
relationshipBetweenSenderAndRecipient.EnsureSendingMessagesIsAllowed(
numberOfUnreceivedMessagesFromActiveIdentity,
_options.MaxNumberOfUnreceivedMessagesFromOneSender);

var recipient = new RecipientInformation(recipientDto.Address, idOfRelationshipBetweenSenderAndRecipient, recipientDto.EncryptedKey);
var recipient = new RecipientInformation(recipientDto.Address, relationshipBetweenSenderAndRecipient.Id, recipientDto.EncryptedKey);

recipients.Add(recipient);
}
Expand Down
23 changes: 23 additions & 0 deletions Modules/Messages/src/Messages.Domain/DomainErrors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Backbone.BuildingBlocks.Domain.Errors;

namespace Backbone.Modules.Messages.Domain;
public static class DomainErrors
{
public static DomainError RelationshipToRecipientNotActive(string recipient = "")
{
var recipientText = string.IsNullOrEmpty(recipient) ? "one of the recipients" : recipient;

return new DomainError(
"error.platform.validation.message.relationshipToRecipientNotActive",
$"Cannot send message to {recipientText} because the relationship to it is not active. In order to be able to send messages again, you have to reactivate the relationship.");
}

public static DomainError MaxNumberOfUnreceivedMessagesReached(string recipient = "")
{
var recipientText = string.IsNullOrEmpty(recipient) ? "one of the recipients" : recipient;

return new DomainError(
"error.platform.validation.message.maxNumberOfUnreceivedMessagesReached",
$"The message could not be sent because {recipientText} already has the maximum number of unread messages from you.");
}
}
27 changes: 25 additions & 2 deletions Modules/Messages/src/Messages.Domain/Entities/Relationship.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Backbone.BuildingBlocks.Domain;
using Backbone.DevelopmentKit.Identity.ValueObjects;
using Backbone.Modules.Messages.Domain.Ids;

Expand All @@ -16,6 +17,15 @@ private Relationship()
Status = default;
}

private Relationship(RelationshipId id, IdentityAddress from, IdentityAddress to, DateTime createdAt, RelationshipStatus status)
{
Id = id;
From = from;
To = to;
CreatedAt = createdAt;
Status = status;
}

public RelationshipId Id { get; }

public IdentityAddress From { get; }
Expand All @@ -24,6 +34,20 @@ private Relationship()
public DateTime CreatedAt { get; }

public RelationshipStatus Status { get; }

public void EnsureSendingMessagesIsAllowed(int numberOfUnreceivedMessagesFromActiveIdentity, int maxNumberOfUnreceivedMessagesFromOneSender)
{
if (Status != RelationshipStatus.Active)
throw new DomainException(DomainErrors.RelationshipToRecipientNotActive(To));

if (numberOfUnreceivedMessagesFromActiveIdentity >= maxNumberOfUnreceivedMessagesFromOneSender)
throw new DomainException(DomainErrors.MaxNumberOfUnreceivedMessagesReached(To));
}

public static Relationship LoadForTesting(RelationshipId id, IdentityAddress from, IdentityAddress to, DateTime createdAt, RelationshipStatus status)
{
return new Relationship(id, from, to, createdAt, status);
}
}

public enum RelationshipStatus
Expand All @@ -32,6 +56,5 @@ public enum RelationshipStatus
Active = 20,
Rejected = 30,
Revoked = 40,
Terminating = 50,
Terminated = 60
Terminated = 50
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,4 @@ public static IQueryable<Relationship> WithParticipants(this IQueryable<Relation
{
return query.Where(r => r.From == participant1 && r.To == participant2 || r.From == participant2 && r.To == participant1);
}

public static IQueryable<Relationship> Active(this IQueryable<Relationship> query)
{
return query.Where(r => r.Status == RelationshipStatus.Active || r.Status == RelationshipStatus.Terminating);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Backbone.DevelopmentKit.Identity.ValueObjects;
using Backbone.Modules.Messages.Application.Infrastructure.Persistence.Repository;
using Backbone.Modules.Messages.Domain.Entities;
using Backbone.Modules.Messages.Domain.Ids;
using Backbone.Modules.Messages.Infrastructure.Persistence.Database.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
Expand All @@ -22,4 +23,12 @@ public RelationshipsRepository(MessagesDbContext dbContext)
.Select(r => r.Id)
.FirstOrDefaultAsync();
}

public Task<Relationship?> FindRelationship(IdentityAddress sender, IdentityAddress recipient, CancellationToken cancellationToken)
{
return _dbContext.Relationships
.AsNoTracking()
.WithParticipants(sender, recipient)
.FirstOrDefaultAsync(cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using Backbone.BuildingBlocks.Domain;
using Backbone.DevelopmentKit.Identity.ValueObjects;
using Backbone.Modules.Messages.Domain.Entities;
using Backbone.Modules.Messages.Domain.Ids;
using Backbone.UnitTestTools.Data;
using FluentAssertions;
using Xunit;

namespace Backbone.Modules.Messages.Domain.Tests.Relationships;
public class RelationshipTests
{
[Fact]
public void Relationship_must_be_active_to_allow_sending_messages()
{
// Arrange
var relationship = CreateRelationship(RelationshipStatus.Pending);

// Act
var acting = () => relationship.EnsureSendingMessagesIsAllowed(0, 5);

// Assert
acting.Should().Throw<DomainException>().Which.Code.Should().Be("error.platform.validation.message.relationshipToRecipientNotActive");
}

[Fact]
public void Max_number_of_unreceived_messages_must_not_be_reached()
{
// Arrange
var relationship = CreateRelationship();

// Act
var acting = () => relationship.EnsureSendingMessagesIsAllowed(5, 5);

// Assert
acting.Should().Throw<DomainException>().Which.Code.Should().Be("error.platform.validation.message.maxNumberOfUnreceivedMessagesReached");
}

[Fact]
public void Relationship_cannot_be_terminated_to_allow_sending_messages()
{
// Arrange
var relationship = CreateRelationship(RelationshipStatus.Terminated);

// Act
var acting = () => relationship.EnsureSendingMessagesIsAllowed(0, 5);

// Assert
acting.Should().Throw<DomainException>().Which.Code.Should().Be("error.platform.validation.message.relationshipToRecipientNotActive");
}

#region helpers

private static Relationship CreateRelationship(RelationshipStatus status)
{
return CreateRelationship(null, null, null, null, status);
}

private static Relationship CreateRelationship(string? relationshipId = null, IdentityAddress? from = null, IdentityAddress? to = null, DateTime? createdAt = null, RelationshipStatus? status = null)
{
relationshipId ??= "REL00000000000000000";
from ??= TestDataGenerator.CreateRandomIdentityAddress();
to ??= TestDataGenerator.CreateRandomIdentityAddress();
createdAt ??= DateTime.UtcNow;
status ??= RelationshipStatus.Active;

return Relationship.LoadForTesting(RelationshipId.Parse(relationshipId), from, to, createdAt.Value, status.Value);
}
#endregion
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
namespace Backbone.Modules.Relationships.Application;

public static class ApplicationErrors;
public static class ApplicationErrors
{
public static class Relationship { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.EventBus;
using Backbone.BuildingBlocks.Application.Abstractions.Infrastructure.UserContext;
using Backbone.DevelopmentKit.Identity.ValueObjects;
using Backbone.Modules.Relationships.Application.Infrastructure.Persistence.Repository;
using Backbone.Modules.Relationships.Application.IntegrationEvents.Outgoing;
using Backbone.Modules.Relationships.Domain.Aggregates.Relationships;
using MediatR;

namespace Backbone.Modules.Relationships.Application.Relationships.Commands.TerminateRelationship;
public class Handler : IRequestHandler<TerminateRelationshipCommand, TerminateRelationshipResponse>
{
private readonly IRelationshipsRepository _relationshipsRepository;
private readonly IEventBus _eventBus;
private readonly IdentityAddress _activeIdentity;
private readonly DeviceId _activeDevice;

public Handler(IRelationshipsRepository relationshipsRepository, IUserContext userContext, IEventBus eventBus)
{
_relationshipsRepository = relationshipsRepository;
_eventBus = eventBus;
_activeIdentity = userContext.GetAddress();
_activeDevice = userContext.GetDeviceId();
}

public async Task<TerminateRelationshipResponse> Handle(TerminateRelationshipCommand request, CancellationToken cancellationToken)
{
var relationshipId = RelationshipId.Parse(request.RelationshipId);
var relationship = await _relationshipsRepository.FindRelationship(relationshipId, _activeIdentity, cancellationToken, track: true);

relationship.Terminate(_activeIdentity, _activeDevice);

await _relationshipsRepository.Update(relationship);

_eventBus.Publish(new RelationshipStatusChangedIntegrationEvent(relationship));

return new TerminateRelationshipResponse(relationship);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Backbone.Modules.Relationships.Application.Relationships.Commands.CreateRelationship;
using MediatR;

namespace Backbone.Modules.Relationships.Application.Relationships.Commands.TerminateRelationship;
public class TerminateRelationshipCommand : IRequest<TerminateRelationshipResponse>
{
public required string RelationshipId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Backbone.Modules.Relationships.Application.Relationships.DTOs;
using Backbone.Modules.Relationships.Domain.Aggregates.Relationships;

namespace Backbone.Modules.Relationships.Application.Relationships.Commands.TerminateRelationship;
public class TerminateRelationshipResponse : RelationshipMetadataDTO
{
public TerminateRelationshipResponse(Relationship relationship) : base(relationship) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ private static string ToDtoStringInternal(this RelationshipStatus status)
RelationshipStatus.Active => "Active",
RelationshipStatus.Rejected => "Rejected",
RelationshipStatus.Revoked => "Revoked",
RelationshipStatus.Terminated => "Terminated",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Backbone.Modules.Relationships.Application.Relationships.Commands.CreateRelationship;
using Backbone.Modules.Relationships.Application.Relationships.Commands.RejectRelationship;
using Backbone.Modules.Relationships.Application.Relationships.Commands.RevokeRelationship;
using Backbone.Modules.Relationships.Application.Relationships.Commands.TerminateRelationship;
using Backbone.Modules.Relationships.Application.Relationships.DTOs;
using Backbone.Modules.Relationships.Application.Relationships.Queries.GetRelationship;
using Backbone.Modules.Relationships.Application.Relationships.Queries.ListRelationships;
Expand Down Expand Up @@ -97,6 +98,16 @@ public async Task<IActionResult> RevokeRelationship([FromRoute] string id, Cance
var response = await _mediator.Send(new RevokeRelationshipCommand { RelationshipId = id }, cancellationToken);
return Ok(response);
}

[HttpPut("{id}/Terminate")]
[ProducesResponseType(typeof(HttpResponseEnvelopeResult<TerminateRelationshipResponse>), StatusCodes.Status200OK)]
[ProducesError(StatusCodes.Status400BadRequest)]
[ProducesError(StatusCodes.Status404NotFound)]
public async Task<IActionResult> TerminateRelationship([FromRoute] string id, CancellationToken cancellationToken)
{
await _mediator.Send(new TerminateRelationshipCommand() { RelationshipId = id }, cancellationToken);
return NoContent();
}
}

public class AcceptRelationshipRequest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ private Relationship()
public Relationship(RelationshipTemplate relationshipTemplate, IdentityAddress activeIdentity, DeviceId activeDevice, byte[]? creationContent, List<Relationship> existingRelationships)
{
EnsureTargetIsNotSelf(relationshipTemplate, activeIdentity);
EnsureNoActiveRelationshipToTargetExists(relationshipTemplate.CreatedBy, existingRelationships);
EnsureNoOtherRelationshipToPeerExists(relationshipTemplate.CreatedBy, existingRelationships);

Id = RelationshipId.New();
RelationshipTemplateId = relationshipTemplate.Id;
Expand Down Expand Up @@ -65,9 +65,9 @@ private static void EnsureTargetIsNotSelf(RelationshipTemplate relationshipTempl
throw new DomainException(DomainErrors.CannotSendRelationshipRequestToYourself());
}

private static void EnsureNoActiveRelationshipToTargetExists(IdentityAddress target, List<Relationship> existingRelationships)
private static void EnsureNoOtherRelationshipToPeerExists(IdentityAddress target, IEnumerable<Relationship> existingRelationshipsToPeer)
{
if (existingRelationships.Any(r => r.Status == RelationshipStatus.Active))
if (existingRelationshipsToPeer.Any(r => r.Status is RelationshipStatus.Active or RelationshipStatus.Pending or RelationshipStatus.Terminated))
throw new DomainException(DomainErrors.RelationshipToTargetAlreadyExists(target));
}

Expand Down Expand Up @@ -141,6 +141,22 @@ public void Revoke(IdentityAddress activeIdentity, DeviceId activeDevice)
AuditLog.Add(auditLogEntry);
}

public void Terminate(IdentityAddress activeIdentity, DeviceId activeDevice)
{
EnsureStatus(RelationshipStatus.Active);

Status = RelationshipStatus.Terminated;

var auditLogEntry = new RelationshipAuditLogEntry(
RelationshipAuditLogEntryReason.Termination,
RelationshipStatus.Active,
RelationshipStatus.Terminated,
activeIdentity,
activeDevice
);
AuditLog.Add(auditLogEntry);
}

#region Expressions

public static Expression<Func<Relationship, bool>> HasParticipant(string identity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public enum RelationshipAuditLogEntryReason
Creation = 0,
AcceptanceOfCreation = 1,
RejectionOfCreation = 2,
RevocationOfCreation = 3
RevocationOfCreation = 3,
Termination = 4
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ public enum RelationshipStatus
Active = 20,
Rejected = 30,
Revoked = 40,
Terminated = 50
}
Loading

0 comments on commit bb3ccbb

Please sign in to comment.