Skip to content

Commit

Permalink
feat: Add SeenBy per user (#368)
Browse files Browse the repository at this point in the history
Adds SeenBy per user in activities

---------

Co-authored-by: Magnus Sandgren <[email protected]>
  • Loading branch information
oskogstad and MagnusSandgren authored Feb 1, 2024
1 parent 4b98348 commit c68db9e
Show file tree
Hide file tree
Showing 32 changed files with 1,552 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,30 @@ public static class ClaimsPrincipalExtensions
private const char IdDelimiter = ':';
private const string IdPrefix = "0192";
private const string OrgClaim = "urn:altinn:org";
private const string PidClaim = "pid";

public static bool TryGetOrgNumber(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? orgNumber)
=> claimsPrincipal.FindFirst(ConsumerClaim).TryGetOrgNumber(out orgNumber);

public static bool TryGetPid(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? pid)
=> claimsPrincipal.FindFirst(PidClaim).TryGetPid(out pid);

public static bool TryGetPid(this Claim? pidClaim, [NotNullWhen(true)] out string? pid)
{
pid = null;
if (pidClaim is null || pidClaim.Type != PidClaim)
{
return false;
}

if (NorwegianPersonIdentifier.IsValid(pidClaim.Value))
{
pid = pidClaim.Value;
}

return pid is not null;
}

public static bool TryGetOrgNumber(this Claim? consumerClaim, [NotNullWhen(true)] out string? orgNumber)
{
orgNumber = null;
Expand Down Expand Up @@ -54,10 +74,10 @@ public static bool TryGetOrgNumber(this Claim? consumerClaim, [NotNullWhen(true)
return orgNumber is not null;
}

public static bool TryGetOrgShortName(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? orgShortName)
private static bool TryGetOrgShortName(this ClaimsPrincipal claimsPrincipal, [NotNullWhen(true)] out string? orgShortName)
=> claimsPrincipal.FindFirst(OrgClaim).TryGetOrgShortName(out orgShortName);

public static bool TryGetOrgShortName(this Claim? orgClaim, [NotNullWhen(true)] out string? orgShortName)
private static bool TryGetOrgShortName(this Claim? orgClaim, [NotNullWhen(true)] out string? orgShortName)
{
orgShortName = orgClaim?.Value;

Expand All @@ -69,4 +89,7 @@ internal static bool TryGetOrgNumber(this IUser user, [NotNullWhen(true)] out st

internal static bool TryGetOrgShortName(this IUser user, [NotNullWhen(true)] out string? orgShortName) =>
user.GetPrincipal().TryGetOrgShortName(out orgShortName);

internal static bool TryGetPid(this IUser user, [NotNullWhen(true)] out string? pid) =>
user.GetPrincipal().TryGetPid(out pid);
}
22 changes: 20 additions & 2 deletions src/Digdir.Domain.Dialogporten.Application/Common/IUserService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Digdir.Domain.Dialogporten.Application.Externals.Presentation;
using Digdir.Domain.Dialogporten.Application.Externals;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Digdir.Domain.Dialogporten.Application.Common.Extensions;

namespace Digdir.Domain.Dialogporten.Application.Common;
Expand All @@ -10,22 +11,27 @@ internal interface IUserService
Task<bool> CurrentUserIsOwner(string serviceResource, CancellationToken cancellationToken);
Task<IReadOnlyCollection<string>> GetCurrentUserResourceIds(CancellationToken cancellationToken);
Task<string?> GetCurrentUserOrgShortName(CancellationToken cancellationToken);
bool TryGetCurrentUserPid([NotNullWhen(true)] out string? userPid);
Task<string?> GetCurrentUserName(string personalIdentificationNumber, CancellationToken cancellationToken);
}

internal sealed class UserService : IUserService
{
private readonly IUser _user;
private readonly IResourceRegistry _resourceRegistry;
private readonly IOrganizationRegistry _organizationRegistry;
private readonly INameRegistry _nameRegistry;

public UserService(
IUser user,
IResourceRegistry resourceRegistry,
IOrganizationRegistry organizationRegistry)
IOrganizationRegistry organizationRegistry,
INameRegistry nameRegistry)
{
_user = user ?? throw new ArgumentNullException(nameof(user));
_resourceRegistry = resourceRegistry ?? throw new ArgumentNullException(nameof(resourceRegistry));
_organizationRegistry = organizationRegistry ?? throw new ArgumentNullException(nameof(organizationRegistry));
_nameRegistry = nameRegistry ?? throw new ArgumentNullException(nameof(nameRegistry));
}

public async Task<bool> CurrentUserIsOwner(string serviceResource, CancellationToken cancellationToken)
Expand All @@ -35,10 +41,15 @@ public async Task<bool> CurrentUserIsOwner(string serviceResource, CancellationT
}

public Task<IReadOnlyCollection<string>> GetCurrentUserResourceIds(CancellationToken cancellationToken) =>
!_user.TryGetOrgNumber(out var orgNumber)
!_user.TryGetOrgNumber(out var orgNumber)
? throw new UnreachableException()
: _resourceRegistry.GetResourceIds(orgNumber, cancellationToken);

public bool TryGetCurrentUserPid([NotNullWhen(true)] out string? userPid) => _user.TryGetPid(out userPid);

public async Task<string?> GetCurrentUserName(string personalIdentificationNumber,
CancellationToken cancellationToken) => await _nameRegistry.GetName(personalIdentificationNumber, cancellationToken);

public async Task<string?> GetCurrentUserOrgShortName(CancellationToken cancellationToken)
{
if (_user.TryGetOrgShortName(out var orgShortName))
Expand Down Expand Up @@ -72,4 +83,11 @@ public Task<IReadOnlyCollection<string>> GetCurrentUserResourceIds(CancellationT

public Task<string?> GetCurrentUserOrgShortName(CancellationToken cancellationToken) =>
_userService.GetCurrentUserOrgShortName(cancellationToken);

public bool TryGetCurrentUserPid([NotNullWhen(true)] out string? userPid) =>
_userService.TryGetCurrentUserPid(out userPid);


public async Task<string?> GetCurrentUserName(string personalIdentificationNumber, CancellationToken cancellationToken)
=> await Task.FromResult("Local Development User");
}
26 changes: 26 additions & 0 deletions src/Digdir.Domain.Dialogporten.Application/Common/MappingUtils.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System.Security.Cryptography;
using System.Text;

namespace Digdir.Domain.Dialogporten.Application.Common;

internal static class MappingUtils
{
internal static byte[] GetHashSalt(int size = 16) => RandomNumberGenerator.GetBytes(size);

internal static string? HashPid(string? personIdentifier, byte[] salt)
{
if (string.IsNullOrWhiteSpace(personIdentifier))
{
return null;
}

var identifierBytes = Encoding.UTF8.GetBytes(personIdentifier);
Span<byte> buffer = stackalloc byte[identifierBytes.Length + salt.Length];
identifierBytes.CopyTo(buffer);
salt.CopyTo(buffer[identifierBytes.Length..]);

var hashBytes = SHA256.HashData(buffer);

return BitConverter.ToString(hashBytes, 0, 5).Replace("-", "").ToLowerInvariant();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Digdir.Domain.Dialogporten.Application.Externals;

public interface INameRegistry
{
Task<string?> GetName(string personalIdentificationNumber, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal static class CloudEventTypes
DialogCreatedDomainEvent => "dialogporten.dialog.created.v1",
DialogUpdatedDomainEvent => "dialogporten.dialog.updated.v1",
DialogDeletedDomainEvent => "dialogporten.dialog.deleted.v1",
DialogReadDomainEvent => "dialogporten.dialog.read.v1",
DialogSeenDomainEvent => "dialogporten.dialog.seen.v1",

// DialogElement
DialogElementDeletedDomainEvent => "dialogporten.dialog.element.deleted.v1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ internal sealed class DialogEventToAltinnForwarder : DomainEventToAltinnForwarde
INotificationHandler<DialogCreatedDomainEvent>,
INotificationHandler<DialogUpdatedDomainEvent>,
INotificationHandler<DialogDeletedDomainEvent>,
INotificationHandler<DialogReadDomainEvent>
INotificationHandler<DialogSeenDomainEvent>
{
public DialogEventToAltinnForwarder(ICloudEventBus cloudEventBus, IDialogDbContext db,
IOptions<ApplicationSettings> settings)
Expand All @@ -31,7 +31,7 @@ public async Task Handle(DialogUpdatedDomainEvent domainEvent, CancellationToken
await CloudEventBus.Publish(cloudEvent, cancellationToken);
}

public async Task Handle(DialogReadDomainEvent domainEvent, CancellationToken cancellationToken)
public async Task Handle(DialogSeenDomainEvent domainEvent, CancellationToken cancellationToken)
{
var dialog = await GetDialog(domainEvent.DialogId, cancellationToken);
var cloudEvent = CreateCloudEvent(domainEvent, dialog);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public sealed class GetDialogActivityDto
public Guid Id { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public Uri? ExtendedType { get; set; }
public string? SeenByEndUserIdHash { get; init; }

public DialogActivityType.Values Type { get; set; }

Expand All @@ -16,4 +17,4 @@ public sealed class GetDialogActivityDto

public List<LocalizationDto>? PerformedBy { get; set; } = new();
public List<LocalizationDto> Description { get; set; } = new();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AutoMapper;
using Digdir.Domain.Dialogporten.Application.Common;
using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes;
using Digdir.Domain.Dialogporten.Application.Externals;
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
Expand Down Expand Up @@ -73,6 +74,12 @@ public async Task<GetDialogActivityResult> Handle(GetDialogActivityQuery request
return new EntityNotFound<DialogActivity>(request.ActivityId);
}

// Hash end user id
if (activity.SeenByEndUserId is not null)
{
activity.SeenByEndUserId = MappingUtils.HashPid(activity.SeenByEndUserId, MappingUtils.GetHashSalt());
}

return _mapper.Map<GetDialogActivityDto>(activity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@

namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.DialogActivities.Queries.Get;

public class MappingProfile : Profile
internal sealed class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<DialogActivity, GetDialogActivityDto>()
.ForMember(dest => dest.SeenByEndUserIdHash, opt => opt.MapFrom(src => src.SeenByEndUserId))
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ internal sealed class MappingProfile : Profile
public MappingProfile()
{
CreateMap<DialogActivity, SearchDialogActivityDto>()
.ForMember(dest => dest.Type,
opt => opt.MapFrom(src => src.TypeId));
.ForMember(dest => dest.SeenByEndUserIdHash, opt => opt.MapFrom(src => src.SeenByEndUserId))
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public sealed class SearchDialogActivityDto
public Guid Id { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public Uri? ExtendedType { get; set; }
public string? SeenByEndUserIdHash { get; set; }

public DialogActivityType.Values Type { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using AutoMapper;
using Digdir.Domain.Dialogporten.Application.Common;
using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes;
using Digdir.Domain.Dialogporten.Application.Externals;
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
Expand Down Expand Up @@ -61,6 +62,13 @@ public async Task<SearchDialogActivityResult> Handle(SearchDialogActivityQuery r
return new EntityDeleted<DialogEntity>(request.DialogId);
}

// hash end user ids
var salt = MappingUtils.GetHashSalt();
foreach (var activity in dialog.Activities)
{
activity.SeenByEndUserId = MappingUtils.HashPid(activity.SeenByEndUserId, salt);
}

return _mapper.Map<List<SearchDialogActivityDto>>(dialog.Activities);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public sealed class GetDialogDialogActivityDto
public Guid Id { get; set; }
public DateTimeOffset? CreatedAt { get; set; }
public Uri? ExtendedType { get; set; }
public string? SeenByEndUserIdHash { get; init; }

public DialogActivityType.Values Type { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,40 @@ public sealed class GetDialogQuery : IRequest<GetDialogResult>
}

[GenerateOneOf]
public partial class GetDialogResult : OneOfBase<GetDialogDto, EntityNotFound, EntityDeleted> { }
public partial class GetDialogResult : OneOfBase<GetDialogDto, EntityNotFound, EntityDeleted, Forbidden>;

internal sealed class GetDialogQueryHandler : IRequestHandler<GetDialogQuery, GetDialogResult>
{
private readonly IDialogDbContext _db;
private readonly IMapper _mapper;
private readonly IUnitOfWork _unitOfWork;
private readonly ITransactionTime _transactionTime;
private readonly IClock _clock;
private readonly IUserService _userService;
private readonly IAltinnAuthorization _altinnAuthorization;

public GetDialogQueryHandler(
IDialogDbContext db,
IMapper mapper,
IUnitOfWork unitOfWork,
ITransactionTime transactionTime,
IClock clock,
IUserService userService,
IAltinnAuthorization altinnAuthorization)
{
_db = db ?? throw new ArgumentNullException(nameof(db));
_mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
_unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
_transactionTime = transactionTime ?? throw new ArgumentNullException(nameof(transactionTime));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_userService = userService ?? throw new ArgumentNullException(nameof(userService));
_altinnAuthorization = altinnAuthorization ?? throw new ArgumentNullException(nameof(altinnAuthorization));
}

public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationToken cancellationToken)
{
if (!_userService.TryGetCurrentUserPid(out var userPid))
{
return new Forbidden("No valid user pid found.");
}

// This query could be written without all the includes as ProjectTo will do the job for us.
// However, we need to guarantee an order for sub resources of the dialog aggregate.
// This is to ensure that the get is consistent, and that PATCH in the API presentation
Expand Down Expand Up @@ -87,7 +92,10 @@ public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationTo
return new EntityDeleted<DialogEntity>(request.DialogId);
}

dialog.UpdateReadAt(_transactionTime.Value);
var userName = await _userService.GetCurrentUserName(userPid, cancellationToken);
// TODO: What if name lookup fails
// https://github.com/digdir/dialogporten/issues/387
dialog.UpdateSeenAt(userPid, userName);

var saveResult = await _unitOfWork
.WithoutAuditableSideEffects()
Expand All @@ -98,6 +106,13 @@ public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationTo
domainError => throw new UnreachableException("Should not get domain error when updating ReadAt."),
concurrencyError => throw new UnreachableException("Should not get concurrencyError when updating ReadAt."));

// hash end user ids
var salt = MappingUtils.GetHashSalt();
foreach (var activity in dialog.Activities)
{
activity.SeenByEndUserId = MappingUtils.HashPid(activity.SeenByEndUserId, salt);
}

var dto = _mapper.Map<GetDialogDto>(dialog);

DecorateWithAuthorization(dto, authorizationResult);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public MappingProfile()
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusId));

CreateMap<DialogActivity, GetDialogDialogActivityDto>()
.ForMember(dest => dest.SeenByEndUserIdHash, opt => opt.MapFrom(src => src.SeenByEndUserId))
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));

CreateMap<DialogApiAction, GetDialogDialogApiActionDto>();
Expand All @@ -33,4 +34,5 @@ public MappingProfile()
CreateMap<DialogContent, GetDialogContentDto>()
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using AutoMapper;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities;
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Content;

namespace Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Search;
Expand All @@ -9,10 +10,15 @@ internal sealed class MappingProfile : Profile
public MappingProfile()
{
CreateMap<DialogEntity, SearchDialogDto>()
.ForMember(dest => dest.LatestActivities, opt => opt.Ignore())
.ForMember(dest => dest.Content, opt => opt.MapFrom(src => src.Content.Where(x => x.Type.OutputInList)))
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusId));

CreateMap<DialogContent, SearchDialogContentDto>()
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));

CreateMap<DialogActivity, SearchDialogDialogActivityDto>()
.ForMember(dest => dest.SeenByEndUserIdHash, opt => opt.MapFrom(src => src.SeenByEndUserId))
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));
}
}
Loading

0 comments on commit c68db9e

Please sign in to comment.