Skip to content

Commit 71b77d2

Browse files
feat: Split SeenLog from activities (#598)
Co-authored-by: Magnus Sandgren <5285192+MagnusSandgren@users.noreply.github.com>
1 parent a04693f commit 71b77d2

22 files changed

+1611
-167
lines changed

src/Digdir.Domain.Dialogporten.Application/Common/MappingUtils.cs

+5-7
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,18 @@ internal static class MappingUtils
77
{
88
internal static byte[] GetHashSalt(int size = 16) => RandomNumberGenerator.GetBytes(size);
99

10-
internal static string? HashPid(string? personIdentifier, byte[] salt)
10+
internal static string HashPid(string personIdentifier, byte[] salt)
1111
{
12-
if (string.IsNullOrWhiteSpace(personIdentifier))
13-
{
14-
return null;
15-
}
16-
1712
var identifierBytes = Encoding.UTF8.GetBytes(personIdentifier);
1813
Span<byte> buffer = stackalloc byte[identifierBytes.Length + salt.Length];
1914
identifierBytes.CopyTo(buffer);
2015
salt.CopyTo(buffer[identifierBytes.Length..]);
2116

2217
var hashBytes = SHA256.HashData(buffer);
2318

24-
return BitConverter.ToString(hashBytes, 0, 5).Replace("-", "").ToLowerInvariant();
19+
return BitConverter
20+
.ToString(hashBytes, 0, 5)
21+
.Replace("-", "")
22+
.ToLowerInvariant();
2523
}
2624
}

src/Digdir.Domain.Dialogporten.Application/Externals/IDialogDbContext.cs

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public interface IDialogDbContext
2424

2525
DbSet<OutboxMessage> OutboxMessages { get; }
2626
DbSet<OutboxMessageConsumer> OutboxMessageConsumers { get; }
27+
DbSet<DialogSeenRecord> DialogSeenLog { get; }
2728

2829
/// <summary>
2930
/// Validate a property on the <typeparamref name="TEntity"/> using a lambda

src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Events/CloudEventTypes.cs

-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ internal static class CloudEventTypes
2525
nameof(DialogActivityType.Values.Information) => "dialogporten.dialog.activity.information.v1",
2626
nameof(DialogActivityType.Values.Error) => "dialogporten.dialog.activity.error.v1",
2727
nameof(DialogActivityType.Values.Closed) => "dialogporten.dialog.activity.closed.v1",
28-
nameof(DialogActivityType.Values.Seen) => "dialogporten.dialog.activity.seen.v1",
2928
nameof(DialogActivityType.Values.Forwarded) => "dialogporten.dialog.activity.forwarded.v1",
3029

3130
_ => throw new ArgumentOutOfRangeException(nameof(eventName), eventName, null)

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Get/GetDialogActivityDto.cs

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ public sealed class GetDialogActivityDto
88
public Guid Id { get; set; }
99
public DateTimeOffset? CreatedAt { get; set; }
1010
public Uri? ExtendedType { get; set; }
11-
public string? SeenByEndUserIdHash { get; init; }
1211

1312
public DialogActivityType.Values Type { get; set; }
1413

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Get/GetDialogActivityQuery.cs

-6
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,6 @@ public async Task<GetDialogActivityResult> Handle(GetDialogActivityQuery request
7272
return new EntityNotFound<DialogActivity>(request.ActivityId);
7373
}
7474

75-
// Hash end user id
76-
if (activity.SeenByEndUserId is not null)
77-
{
78-
activity.SeenByEndUserId = MappingUtils.HashPid(activity.SeenByEndUserId, MappingUtils.GetHashSalt());
79-
}
80-
8175
return _mapper.Map<GetDialogActivityDto>(activity);
8276
}
8377
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Get/MappingProfile.cs

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ internal sealed class MappingProfile : Profile
88
public MappingProfile()
99
{
1010
CreateMap<DialogActivity, GetDialogActivityDto>()
11-
.ForMember(dest => dest.SeenByEndUserIdHash, opt => opt.MapFrom(src => src.SeenByEndUserId))
1211
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));
1312
}
1413
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Search/MappingProfile.cs

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ internal sealed class MappingProfile : Profile
88
public MappingProfile()
99
{
1010
CreateMap<DialogActivity, SearchDialogActivityDto>()
11-
.ForMember(dest => dest.SeenByEndUserIdHash, opt => opt.MapFrom(src => src.SeenByEndUserId))
1211
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));
1312
}
1413
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogActivities/Queries/Search/SearchDialogActivityQuery.cs

-7
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,6 @@ public async Task<SearchDialogActivityResult> Handle(SearchDialogActivityQuery r
6262
return new EntityDeleted<DialogEntity>(request.DialogId);
6363
}
6464

65-
// hash end user ids
66-
var salt = MappingUtils.GetHashSalt();
67-
foreach (var activity in dialog.Activities)
68-
{
69-
activity.SeenByEndUserId = MappingUtils.HashPid(activity.SeenByEndUserId, salt);
70-
}
71-
7265
return _mapper.Map<List<SearchDialogActivityDto>>(dialog.Activities);
7366
}
7467
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogDto.cs

+13-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,19 @@ public sealed class GetDialogDto
3333
public List<GetDialogDialogGuiActionDto> GuiActions { get; set; } = [];
3434
public List<GetDialogDialogApiActionDto> ApiActions { get; set; } = [];
3535
public List<GetDialogDialogActivityDto> Activities { get; set; } = [];
36+
public List<GetDialogDialogSeenRecordDto> SeenLog { get; set; } = [];
37+
}
38+
39+
public class GetDialogDialogSeenRecordDto
40+
{
41+
public Guid Id { get; set; }
42+
public DateTimeOffset CreatedAt { get; set; }
43+
44+
public string EndUserIdHash { get; set; } = null!;
45+
46+
public string? EndUserName { get; set; }
47+
48+
public bool IsAuthenticatedUser { get; set; }
3649
}
3750

3851
public sealed class GetDialogContentDto
@@ -46,7 +59,6 @@ public sealed class GetDialogDialogActivityDto
4659
public Guid Id { get; set; }
4760
public DateTimeOffset? CreatedAt { get; set; }
4861
public Uri? ExtendedType { get; set; }
49-
public string? SeenByEndUserIdHash { get; init; }
5062

5163
public DialogActivityType.Values Type { get; set; }
5264

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs

+19-12
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationTo
7373
.ThenInclude(x => x.Endpoints.OrderBy(x => x.CreatedAt).ThenBy(x => x.Id))
7474
.Include(x => x.Activities).ThenInclude(x => x.PerformedBy!.Localizations)
7575
.Include(x => x.Activities).ThenInclude(x => x.Description!.Localizations)
76+
.Include(x => x.SeenLog
77+
.Where(x => x.CreatedAt >= x.Dialog.UpdatedAt)
78+
.OrderBy(x => x.CreatedAt))
7679
.Where(x => !x.VisibleFrom.HasValue || x.VisibleFrom < _clock.UtcNowOffset)
7780
.IgnoreQueryFilters()
7881
.FirstOrDefaultAsync(x => x.Id == request.DialogId, cancellationToken);
@@ -110,25 +113,29 @@ public async Task<GetDialogResult> Handle(GetDialogQuery request, CancellationTo
110113
domainError => throw new UnreachableException("Should not get domain error when updating SeenAt."),
111114
concurrencyError => throw new UnreachableException("Should not get concurrencyError when updating SeenAt."));
112115

113-
// hash end user ids
114-
var salt = MappingUtils.GetHashSalt();
115-
foreach (var activity in dialog.Activities)
116-
{
117-
activity.SeenByEndUserId = MappingUtils.HashPid(activity.SeenByEndUserId, salt);
118-
}
116+
var dialogDto = _mapper.Map<GetDialogDto>(dialog);
119117

120-
var dto = _mapper.Map<GetDialogDto>(dialog);
121-
122-
dto.DialogToken = _dialogTokenGenerator.GetDialogToken(
118+
var salt = MappingUtils.GetHashSalt();
119+
dialogDto.SeenLog = dialog.SeenLog
120+
.Select(log =>
121+
{
122+
var logDto = _mapper.Map<GetDialogDialogSeenRecordDto>(log);
123+
logDto.IsAuthenticatedUser = log.EndUserId == userPid;
124+
logDto.EndUserIdHash = MappingUtils.HashPid(log.EndUserId, salt);
125+
return logDto;
126+
})
127+
.ToList();
128+
129+
dialogDto.DialogToken = _dialogTokenGenerator.GetDialogToken(
123130
dialog,
124131
authorizationResult,
125132
"api/v1"
126133
);
127134

128-
DecorateWithAuthorization(dto, authorizationResult);
129-
ReplaceUnauthorizedUrls(dto);
135+
DecorateWithAuthorization(dialogDto, authorizationResult);
136+
ReplaceUnauthorizedUrls(dialogDto);
130137

131-
return dto;
138+
return dialogDto;
132139
}
133140

134141
private static void DecorateWithAuthorization(GetDialogDto dto,

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/MappingProfile.cs

+4-2
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ public MappingProfile()
1313
{
1414
CreateMap<DialogEntity, GetDialogDto>()
1515
.ForMember(dest => dest.Revision, opt => opt.MapFrom(src => src.Revision))
16-
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusId));
16+
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusId))
17+
.ForMember(dest => dest.SeenLog, opt => opt.Ignore());
18+
19+
CreateMap<DialogSeenRecord, GetDialogDialogSeenRecordDto>();
1720

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

2224
CreateMap<DialogApiAction, GetDialogDialogApiActionDto>();

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/MappingProfile.cs

+14-4
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,28 @@ internal sealed class MappingProfile : Profile
1111
public MappingProfile()
1212
{
1313
CreateMap<DialogEntity, SearchDialogDto>()
14-
.ForMember(dest => dest.LatestActivities, opt => opt.Ignore())
15-
.ForMember(dest => dest.GuiAttachmentCount, opt =>
16-
opt.MapFrom(src => src.Elements.Count(x => x.Urls
14+
.ForMember(dest => dest.LatestActivity, opt => opt.MapFrom(src => src.Activities
15+
.Where(activity => activity.TypeId != DialogActivityType.Values.Forwarded)
16+
.OrderByDescending(activity => activity.CreatedAt).ThenByDescending(activity => activity.Id)
17+
.FirstOrDefault()
18+
))
19+
.ForMember(dest => dest.SeenLog, opt => opt.MapFrom(src => src.SeenLog
20+
.Where(x => x.CreatedAt >= x.Dialog.UpdatedAt)
21+
.OrderByDescending(x => x.CreatedAt)
22+
))
23+
.ForMember(dest => dest.GuiAttachmentCount, opt => opt.MapFrom(src => src.Elements
24+
.Count(x => x.Urls
1725
.Any(url => url.ConsumerTypeId == DialogElementUrlConsumerType.Values.Gui))))
1826
.ForMember(dest => dest.Content, opt => opt.MapFrom(src => src.Content.Where(x => x.Type.OutputInList)))
1927
.ForMember(dest => dest.Status, opt => opt.MapFrom(src => src.StatusId));
2028

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

32+
CreateMap<DialogSeenRecord, SearchDialogDialogSeenRecordDto>()
33+
.ForMember(dest => dest.EndUserIdHash, opt => opt.MapFrom(src => src.EndUserId));
34+
2435
CreateMap<DialogActivity, SearchDialogDialogActivityDto>()
25-
.ForMember(dest => dest.SeenByEndUserIdHash, opt => opt.MapFrom(src => src.SeenByEndUserId))
2636
.ForMember(dest => dest.Type, opt => opt.MapFrom(src => src.TypeId));
2737
}
2838
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogDto.cs

+15-3
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,22 @@ public sealed class SearchDialogDto
2020

2121
public DialogStatus.Values Status { get; set; }
2222

23+
public SearchDialogDialogActivityDto? LatestActivity { get; set; }
24+
2325
public List<SearchDialogContentDto> Content { get; set; } = [];
24-
public List<SearchDialogDialogActivityDto> LatestActivities { get; set; } = [];
26+
public List<SearchDialogDialogSeenRecordDto> SeenLog { get; set; } = [];
27+
}
28+
29+
public class SearchDialogDialogSeenRecordDto
30+
{
31+
public Guid Id { get; set; }
32+
public DateTimeOffset CreatedAt { get; set; }
33+
34+
public string EndUserIdHash { get; set; } = null!;
35+
36+
public string? EndUserName { get; set; }
37+
38+
public bool IsAuthenticatedUser { get; set; }
2539
}
2640

2741
public sealed class SearchDialogContentDto
@@ -35,8 +49,6 @@ public sealed class SearchDialogDialogActivityDto
3549
public Guid Id { get; set; }
3650
public DateTimeOffset? CreatedAt { get; set; }
3751
public Uri? ExtendedType { get; set; }
38-
public string? SeenByEndUserIdHash { get; set; }
39-
public bool? SeenActivityIsCurrentEndUser { get; set; }
4052

4153
public DialogActivityType.Values Type { get; set; }
4254

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Search/SearchDialogQuery.cs

+8-58
Original file line numberDiff line numberDiff line change
@@ -175,66 +175,16 @@ public async Task<SearchDialogResult> Handle(SearchDialogQuery request, Cancella
175175
.ProjectTo<SearchDialogDto>(_mapper.ConfigurationProvider)
176176
.ToPaginatedListAsync(request, cancellationToken: cancellationToken);
177177

178-
await FetchRelevantActivities(paginatedList, userPid, cancellationToken);
179-
180-
return paginatedList;
181-
}
182-
183-
private async Task FetchRelevantActivities(PaginatedList<SearchDialogDto> paginatedList, string userPid, CancellationToken cancellationToken)
184-
{
185-
var dialogIds = paginatedList.Items
186-
.Select(x => x.Id)
187-
.ToList();
188-
189-
var latestActivityByDialogIdTask = await _db.DialogActivities
190-
.AsNoTracking()
191-
.Include(x => x.Description!.Localizations)
192-
.Include(x => x.PerformedBy!.Localizations)
193-
.Where(x =>
194-
dialogIds.Contains(x.DialogId)
195-
&& x.TypeId != DialogActivityType.Values.Forwarded
196-
&& x.TypeId != DialogActivityType.Values.Seen)
197-
.GroupBy(x => x.DialogId)
198-
.ToDictionaryAsync(
199-
x => x.Key,
200-
x => x.OrderByDescending(x => x.CreatedAt)
201-
.ThenBy(x => x.Id)
202-
.First(),
203-
cancellationToken);
204-
205-
var latestSeenActivityByDialogIdTask = await _db.DialogActivities
206-
.AsNoTracking()
207-
.Include(x => x.Description!.Localizations)
208-
.Include(x => x.PerformedBy!.Localizations)
209-
.Where(x =>
210-
dialogIds.Contains(x.DialogId)
211-
&& x.TypeId == DialogActivityType.Values.Seen
212-
&& x.CreatedAt > x.Dialog.UpdatedAt)
213-
.GroupBy(x => x.DialogId)
214-
.ToDictionaryAsync(x => x.Key, x => x.ToList(), cancellationToken);
215-
216178
var salt = MappingUtils.GetHashSalt();
217-
foreach (var dialog in paginatedList.Items)
179+
foreach (var seenLog in paginatedList.Items.SelectMany(x => x.SeenLog))
218180
{
219-
var activities = latestSeenActivityByDialogIdTask.TryGetValue(dialog.Id, out var seenActivities)
220-
? seenActivities
221-
: [];
222-
223-
if (latestActivityByDialogIdTask.TryGetValue(dialog.Id, out var latestNonSeenActivity))
224-
{
225-
activities.Add(latestNonSeenActivity);
226-
}
227-
228-
dialog.LatestActivities = _mapper.Map<List<SearchDialogDialogActivityDto>>(activities);
229-
230-
foreach (var activity in dialog.LatestActivities
231-
.Where(x => !string.IsNullOrWhiteSpace(x.SeenByEndUserIdHash)))
232-
{
233-
// Before we hash the end user id, check if the seen activity is for the current user
234-
activity.SeenActivityIsCurrentEndUser = userPid == activity.SeenByEndUserIdHash;
235-
// Hash end user ids
236-
activity.SeenByEndUserIdHash = MappingUtils.HashPid(activity.SeenByEndUserIdHash, salt);
237-
}
181+
// Before we hash the end user id, check if the seen log entry is for the current user
182+
seenLog.IsAuthenticatedUser = userPid == seenLog.EndUserIdHash;
183+
// TODO: Add test to not expose unhashed end user id to the client
184+
// https://github.com/digdir/dialogporten/issues/596
185+
seenLog.EndUserIdHash = MappingUtils.HashPid(seenLog.EndUserIdHash, salt);
238186
}
187+
188+
return paginatedList;
239189
}
240190
}

src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/Activities/DialogActivity.cs

-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ public class DialogActivity : IImmutableEntity, IAggregateCreatedHandler, IEvent
1212
public Guid Id { get; set; }
1313
public DateTimeOffset CreatedAt { get; set; }
1414
public Uri? ExtendedType { get; set; }
15-
public string? SeenByEndUserId { get; set; }
1615

1716
// === Dependent relationships ===
1817
public DialogActivityType.Values TypeId { get; set; }

src/Digdir.Domain.Dialogporten.Domain/Dialogs/Entities/Activities/DialogActivityType.cs

+3-11
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,18 @@ public enum Values
1515
Submission = 1,
1616

1717
/// <summary>
18-
/// Indikerer en tilbakemelding fra tjenestetilbyder på en innsending. Inneholder
18+
/// Indikerer en tilbakemelding fra tjenestetilbyder på en innsending. Inneholder
1919
/// referanse til den aktuelle innsendingen.
2020
/// </summary>
2121
Feedback = 2,
2222

2323
/// <summary>
24-
/// Informasjon fra tjenestetilbyder, ikke (direkte) relatert til noen innsending.
24+
/// Informasjon fra tjenestetilbyder, ikke (direkte) relatert til noen innsending.
2525
/// </summary>
2626
Information = 3,
2727

2828
/// <summary>
29-
/// Brukes for å indikere en feilsituasjon, typisk på en innsending. Inneholder en
29+
/// Brukes for å indikere en feilsituasjon, typisk på en innsending. Inneholder en
3030
/// tjenestespesifikk activityErrorCode.
3131
/// </summary>
3232
Error = 4,
@@ -37,14 +37,6 @@ public enum Values
3737
/// </summary>
3838
Closed = 5,
3939

40-
/// <summary>
41-
/// Når dialogen først ble hentet og av hvem. Kan brukes for å avgjøre om purring
42-
/// skal sendes ut, eller internt i virksomheten for å tracke tilganger/bruker.
43-
/// Merk at dette ikke er det samme som "lest", dette må tjenestetilbyder selv håndtere
44-
/// i egne løsninger.
45-
/// </summary>
46-
Seen = 6,
47-
4840
/// <summary>
4941
/// Når dialogen blir videresendt (tilgang delegert) av noen med tilgang til andre.
5042
/// </summary>

0 commit comments

Comments
 (0)