From 3225f021b34ec95ff7ca564db68c8630d8cbeda0 Mon Sep 17 00:00:00 2001 From: Danielle Petersen Date: Mon, 1 Jun 2026 14:55:57 +0200 Subject: [PATCH 01/16] DN-3759 feat: Added related users to the project and context --- Examples/Projects/Context/Entity.yaml | 8 +++- Examples/Projects/Project/Entity.yaml | 30 ++++++++++++- UvA.Workflow.Api/Users/Dtos/UserDto.cs | 6 +++ .../Dtos/WorkflowInstanceDto.cs | 21 ++++++++- .../Dtos/WorkflowInstanceDtoFactory.cs | 43 ++++++++++++++++++- UvA.Workflow/WorkflowModel/RelatedUser.cs | 38 ++++++++++++++++ .../WorkflowModel/WorkflowDefinition.cs | 4 ++ 7 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 UvA.Workflow/WorkflowModel/RelatedUser.cs diff --git a/Examples/Projects/Context/Entity.yaml b/Examples/Projects/Context/Entity.yaml index 4502ea48..563b28c2 100644 --- a/Examples/Projects/Context/Entity.yaml +++ b/Examples/Projects/Context/Entity.yaml @@ -16,4 +16,10 @@ properties: - name: Specialisation - name: Coordinator - type: "[User]!" \ No newline at end of file + type: "[User]!" + + - name: ConfidentialAdviser + type: User! + + - name: StudyAdvisor + type: User! \ No newline at end of file diff --git a/Examples/Projects/Project/Entity.yaml b/Examples/Projects/Project/Entity.yaml index 39f6a3b2..01794a4f 100644 --- a/Examples/Projects/Project/Entity.yaml +++ b/Examples/Projects/Project/Entity.yaml @@ -12,9 +12,35 @@ fields: - property: Title - currentStep: true - property: TurnitinId - + steps: - Subject - Upload - Assessment - - Publication \ No newline at end of file + - Publication + +relatedUsers: + - property: Supervisor + group: default + - property: Examiner + group: default + - property: Reviewer + group: default + - property: Course.ConfidentialAdviser + group: support + text: + en: Confidential adviser + nl: Vertrouwenspersoon + - property: Course.StudyAdvisor + group: support + text: + en: Study advisor + nl: Studieadviseur + +relatedUserGrouping: + groups: + - name: default + - name: support + title: + en: Other involved staff + nl: Andere betrokkenen \ No newline at end of file diff --git a/UvA.Workflow.Api/Users/Dtos/UserDto.cs b/UvA.Workflow.Api/Users/Dtos/UserDto.cs index 8b778a80..f7ce92cc 100644 --- a/UvA.Workflow.Api/Users/Dtos/UserDto.cs +++ b/UvA.Workflow.Api/Users/Dtos/UserDto.cs @@ -28,4 +28,10 @@ public static UserDto Create(User user) UserProviderKeys.IsExternal(user.ProviderKey) ); } + + /// + /// Creates a UserDto from an Instance User entity + /// + public static UserDto CreateFromInstanceUser(InstanceUser u) => + new(u.Id, u.UserName, u.DisplayName, u.Email, u.PreferredLanguage, u.Organization, u.IsExternal); } \ No newline at end of file diff --git a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs index f7264ce0..d40294af 100644 --- a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs +++ b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs @@ -1,4 +1,5 @@ using UvA.Workflow.Api.Submissions.Dtos; +using UvA.Workflow.Api.Users.Dtos; using UvA.Workflow.Api.WorkflowDefinitions.Dtos; using UvA.Workflow.Events; using UvA.Workflow.Notifications; @@ -23,7 +24,8 @@ public record WorkflowInstanceDto( SubmissionDto[] Submissions, RoleAction[] Permissions, bool CanUseAdminTools, - string[] ViewerRoles + string[] ViewerRoles, + RelatedUserGroupsDto RelatedUserGroups ); public record FieldDto(string? Key, BilingualString Title, object? Value); @@ -115,4 +117,19 @@ public static InstanceEventDto Create(InstanceEvent instanceEvent) { return new InstanceEventDto(instanceEvent.Id, instanceEvent.Date); } -} \ No newline at end of file +} + +public record RelatedUserDto( + BilingualString Title, + UserDto User +); + +public record RelatedUserGroupDto( + string Name, + BilingualString Title, + RelatedUserDto[] Users +); + +public record RelatedUserGroupsDto( + RelatedUserGroupDto[] Groups +); \ No newline at end of file diff --git a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs index 763f8837..6842f3b1 100644 --- a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs +++ b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs @@ -1,4 +1,5 @@ using UvA.Workflow.Api.Submissions.Dtos; +using UvA.Workflow.Api.Users.Dtos; using UvA.Workflow.Api.WorkflowDefinitions.Dtos; using UvA.Workflow.Versioning; using UvA.Workflow.WorkflowModel; @@ -31,13 +32,18 @@ public async Task Create(WorkflowInstance instance, Cancell RoleAction.ViewAdminTools, RightsEvaluationMode.RealUser); var viewerRoles = await rightsService.GetViewerRoles(instance, ct); + var context = modelService.CreateContext(instance); + var relatedUserLookups = workflowDefinition.RelatedUsers + .Select(r => (Lookup)new PropertyLookup(r.Property)); await instanceService.Enrich(workflowDefinition, [context], - workflowDefinition.Steps.SelectMany(f => f.Lookups), ct); + workflowDefinition.Steps.SelectMany(f => f.Lookups).Concat(relatedUserLookups), ct); // Fetch versions for all steps var stepVersionsMap = await GetStepVersionsMap(instance, workflowDefinition.AllSteps, ct); + var relatedUsers = GetRelatedUsers(workflowDefinition, context); + var x = new WorkflowInstanceDto( instance.Id, workflowDefinition.InstanceTitleTemplate?.Apply(modelService.CreateContext(instance)), @@ -56,7 +62,8 @@ await instanceService.Enrich(workflowDefinition, [context], .ToArray(), permissions.Where(a => a.AllForms.Length == 0).Select(a => a.Type).Distinct().ToArray(), canUseAdminTools, - viewerRoles + viewerRoles, + relatedUsers ); return x; } @@ -201,4 +208,36 @@ private StepVersionDto CreateStepVersionDto(StepVersion stepVersion, WorkflowIns throw; } } + + private RelatedUserGroupsDto GetRelatedUsers(WorkflowDefinition workflowDefinition, ObjectContext context) + { + // Resolve each RelatedUser to its user value, keyed by group name + var usersByGroup = workflowDefinition.RelatedUsers + .Select(relatedUser => + { + var value = context.Get(relatedUser.Property); + if (value is not InstanceUser user) return null; + + return new + { + relatedUser.Group, + Dto = new RelatedUserDto(relatedUser.DisplayTitle, UserDto.CreateFromInstanceUser(user)) + }; + }) + .Where(x => x is not null) + .GroupBy(x => x!.Group) + .ToDictionary(g => g.Key, g => g.Select(x => x!.Dto).ToArray()); + + // Build groups in the order defined, only including those with at least one resolved user + var groups = (workflowDefinition.RelatedUserGrouping?.Groups ?? []) + .Where(g => usersByGroup.ContainsKey(g.Name)) + .Select(g => new RelatedUserGroupDto( + g.Name, + g.Title, + usersByGroup[g.Name] + )) + .ToArray(); + + return new RelatedUserGroupsDto(groups); + } } \ No newline at end of file diff --git a/UvA.Workflow/WorkflowModel/RelatedUser.cs b/UvA.Workflow/WorkflowModel/RelatedUser.cs new file mode 100644 index 00000000..c987bca9 --- /dev/null +++ b/UvA.Workflow/WorkflowModel/RelatedUser.cs @@ -0,0 +1,38 @@ +namespace UvA.Workflow.WorkflowModel; + +public class RelatedUser +{ + public string Property { get; set; } = null!; + + public string Group { get; set; } + + public BilingualString? Text { get; set; } = null; + + [YamlIgnore] public PropertyDefinition? PropertyDefinition { get; set; } + + public BilingualString DisplayTitle => Text ?? PropertyDefinition?.DisplayName ?? Property; +} + +/// +/// Defines a named group of related users +/// +public class RelatedUserGroup +{ + /// + /// Internal identifier for the group + /// + public string Name { get; set; } = null!; + + /// + /// Display name of the group + /// + public BilingualString Title { get; set; } = null!; +} + +/// +/// Configuration for grouping related users +/// +public class RelatedUserGrouping +{ + public RelatedUserGroup[] Groups { get; set; } = []; +} \ No newline at end of file diff --git a/UvA.Workflow/WorkflowModel/WorkflowDefinition.cs b/UvA.Workflow/WorkflowModel/WorkflowDefinition.cs index 8aa20d92..ab3ab127 100644 --- a/UvA.Workflow/WorkflowModel/WorkflowDefinition.cs +++ b/UvA.Workflow/WorkflowModel/WorkflowDefinition.cs @@ -75,6 +75,10 @@ public class WorkflowDefinition : INamed /// public Field[] Fields { get; set; } = []; + public RelatedUser[] RelatedUsers { get; set; } = []; + + public RelatedUserGrouping? RelatedUserGrouping { get; set; } + /// /// Indicated whether this entity type is stored as an embedded document in the parent instance /// From aa8d29c9e0bec3f1e54afa3010c1ef2d7569d3ce Mon Sep 17 00:00:00 2001 From: Danielle Petersen Date: Mon, 1 Jun 2026 15:59:05 +0200 Subject: [PATCH 02/16] DN-3759 feat: User array support --- Examples/Projects/Project/Entity.yaml | 5 +++++ .../Dtos/WorkflowInstanceDtoFactory.cs | 19 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Examples/Projects/Project/Entity.yaml b/Examples/Projects/Project/Entity.yaml index 01794a4f..23406fc9 100644 --- a/Examples/Projects/Project/Entity.yaml +++ b/Examples/Projects/Project/Entity.yaml @@ -26,6 +26,11 @@ relatedUsers: group: default - property: Reviewer group: default + - property: Course.Coordinator + group: support + text: + en: Coordinator + nl: Coördinator - property: Course.ConfidentialAdviser group: support text: diff --git a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs index 6842f3b1..8e6c6006 100644 --- a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs +++ b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs @@ -213,20 +213,25 @@ private RelatedUserGroupsDto GetRelatedUsers(WorkflowDefinition workflowDefiniti { // Resolve each RelatedUser to its user value, keyed by group name var usersByGroup = workflowDefinition.RelatedUsers - .Select(relatedUser => + .SelectMany(relatedUser => { var value = context.Get(relatedUser.Property); - if (value is not InstanceUser user) return null; - return new + var users = value switch + { + InstanceUser user => [user], + InstanceUser[] arr => arr, + _ => [] + }; + + return users.Select(user => new { relatedUser.Group, Dto = new RelatedUserDto(relatedUser.DisplayTitle, UserDto.CreateFromInstanceUser(user)) - }; + }); }) - .Where(x => x is not null) - .GroupBy(x => x!.Group) - .ToDictionary(g => g.Key, g => g.Select(x => x!.Dto).ToArray()); + .GroupBy(x => x.Group) + .ToDictionary(g => g.Key, g => g.Select(x => x.Dto).ToArray()); // Build groups in the order defined, only including those with at least one resolved user var groups = (workflowDefinition.RelatedUserGrouping?.Groups ?? []) From 753c4aac3265ab5697bba5369f31fd07ece23a11 Mon Sep 17 00:00:00 2001 From: Danielle Petersen Date: Mon, 1 Jun 2026 16:12:43 +0200 Subject: [PATCH 03/16] DN-3759 feat: Support for user arrays --- Examples/Projects/Project/Entity.yaml | 2 ++ Examples/Projects/Project/Properties.yaml | 7 +++++++ .../WorkflowInstances/Dtos/WorkflowInstanceDto.cs | 2 +- .../WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Examples/Projects/Project/Entity.yaml b/Examples/Projects/Project/Entity.yaml index 23406fc9..67e407d8 100644 --- a/Examples/Projects/Project/Entity.yaml +++ b/Examples/Projects/Project/Entity.yaml @@ -26,6 +26,8 @@ relatedUsers: group: default - property: Reviewer group: default + - property: SecondReader + group: default - property: Course.Coordinator group: support text: diff --git a/Examples/Projects/Project/Properties.yaml b/Examples/Projects/Project/Properties.yaml index 48ca970c..5a1ca90e 100644 --- a/Examples/Projects/Project/Properties.yaml +++ b/Examples/Projects/Project/Properties.yaml @@ -39,6 +39,13 @@ properties: text: en: Reviewer nl: Beoordelaar + + - name: SecondReader + type: User! + text: + en: Second reader + nl: Tweede beoordelaar + - name: Student type: User! diff --git a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs index d40294af..fbaa4234 100644 --- a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs +++ b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs @@ -121,7 +121,7 @@ public static InstanceEventDto Create(InstanceEvent instanceEvent) public record RelatedUserDto( BilingualString Title, - UserDto User + UserDto? User ); public record RelatedUserGroupDto( diff --git a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs index 8e6c6006..6810df1c 100644 --- a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs +++ b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs @@ -224,6 +224,10 @@ private RelatedUserGroupsDto GetRelatedUsers(WorkflowDefinition workflowDefiniti _ => [] }; + // Always emit at least one entry with null user if nothing resolved + if (users.Length == 0) + return [new { relatedUser.Group, Dto = new RelatedUserDto(relatedUser.DisplayTitle, null) }]; + return users.Select(user => new { relatedUser.Group, From 097d3c192ab3f31da659061793a86b42b3dd238b Mon Sep 17 00:00:00 2001 From: Danielle Petersen Date: Tue, 2 Jun 2026 15:38:17 +0200 Subject: [PATCH 04/16] DN-3759 feat: User pending logic from the new user ticket --- UvA.Workflow.Api/Users/Dtos/UserDto.cs | 9 ++++++--- UvA.Workflow/Users/InstanceUser.cs | 5 ++++- UvA.Workflow/Users/User.cs | 12 ++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/UvA.Workflow.Api/Users/Dtos/UserDto.cs b/UvA.Workflow.Api/Users/Dtos/UserDto.cs index f7ce92cc..5e163d15 100644 --- a/UvA.Workflow.Api/Users/Dtos/UserDto.cs +++ b/UvA.Workflow.Api/Users/Dtos/UserDto.cs @@ -10,7 +10,8 @@ public record UserDto( string Email, string? PreferredLanguage, Organization? Organization, - bool IsExternal + bool IsExternal, + bool IsPending ) { /// @@ -25,7 +26,8 @@ public static UserDto Create(User user) user.Email, user.PreferredLanguage, user.Organization, - UserProviderKeys.IsExternal(user.ProviderKey) + UserProviderKeys.IsExternal(user.ProviderKey), + user.InvitationState == UserInvitationState.Pending ); } @@ -33,5 +35,6 @@ public static UserDto Create(User user) /// Creates a UserDto from an Instance User entity /// public static UserDto CreateFromInstanceUser(InstanceUser u) => - new(u.Id, u.UserName, u.DisplayName, u.Email, u.PreferredLanguage, u.Organization, u.IsExternal); + new(u.Id, u.UserName, u.DisplayName, u.Email, u.PreferredLanguage, u.Organization, u.IsExternal, + u.InvitationState == UserInvitationState.Pending); } \ No newline at end of file diff --git a/UvA.Workflow/Users/InstanceUser.cs b/UvA.Workflow/Users/InstanceUser.cs index 0262fd49..7a7163b0 100644 --- a/UvA.Workflow/Users/InstanceUser.cs +++ b/UvA.Workflow/Users/InstanceUser.cs @@ -23,6 +23,8 @@ public class InstanceUser [BsonElement("IsExternal")] public bool IsExternal { get; set; } + [BsonElement("InvitationState")] public UserInvitationState? InvitationState { get; set; } = null; + public static InstanceUser FromUser(User user) => new() { Id = user.Id, @@ -31,6 +33,7 @@ public class InstanceUser Email = user.Email, PreferredLanguage = user.PreferredLanguage, Organization = user.Organization, - IsExternal = UserProviderKeys.IsExternal(user.ProviderKey) + IsExternal = UserProviderKeys.IsExternal(user.ProviderKey), + InvitationState = user.InvitationState }; } \ No newline at end of file diff --git a/UvA.Workflow/Users/User.cs b/UvA.Workflow/Users/User.cs index 2bf3b449..44daf40d 100644 --- a/UvA.Workflow/Users/User.cs +++ b/UvA.Workflow/Users/User.cs @@ -2,6 +2,12 @@ namespace UvA.Workflow.Users; +public enum UserInvitationState +{ + Required, + Pending +} + /// /// Represents a user in the workflow system. /// @@ -29,4 +35,10 @@ public class User public string ProviderKey { get; set; } = UserProviderKeys.Internal; [BsonElement("IsActive")] [JsonIgnore] public bool IsActive { get; set; } = true; + + [BsonElement("InvitationState")] + [BsonRepresentation(BsonType.String)] + [BsonIgnoreIfNull] + [JsonIgnore] + public UserInvitationState? InvitationState { get; set; } = null; } \ No newline at end of file From b3e5f054c693fddf3543f792f4fa9747a4f57cbd Mon Sep 17 00:00:00 2001 From: Danielle Petersen Date: Thu, 11 Jun 2026 14:55:02 +0200 Subject: [PATCH 05/16] DN-3759 feat: Added titles and some merge fixes --- Examples/Projects/Project/Entity.yaml | 12 ++++++++++++ UvA.Workflow.Api/Users/Dtos/UserDto.cs | 2 +- .../Dtos/WorkflowInstanceDtoFactory.cs | 11 +---------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Examples/Projects/Project/Entity.yaml b/Examples/Projects/Project/Entity.yaml index 69b4d4da..fcbf1f98 100644 --- a/Examples/Projects/Project/Entity.yaml +++ b/Examples/Projects/Project/Entity.yaml @@ -26,12 +26,24 @@ steps: relatedUsers: - property: Supervisor group: default + text: + en: Supervisor + nl: Begeleider - property: Examiner group: default + text: + en: Examiner + nl: Examinator - property: Reviewer group: default + text: + en: Reviewer + nl: Beoordelaar - property: SecondReader group: default + text: + en: Second reader + nl: 2e lezer - property: Course.Coordinator group: support text: diff --git a/UvA.Workflow.Api/Users/Dtos/UserDto.cs b/UvA.Workflow.Api/Users/Dtos/UserDto.cs index bb99cdb8..ab42e9ce 100644 --- a/UvA.Workflow.Api/Users/Dtos/UserDto.cs +++ b/UvA.Workflow.Api/Users/Dtos/UserDto.cs @@ -40,5 +40,5 @@ public static UserDto Create(User user, bool isSuperAdmin = false) /// public static UserDto CreateFromInstanceUser(InstanceUser u) => new(u.Id, u.UserName, u.DisplayName, u.Email, u.PreferredLanguage, u.Organization, u.IsExternal, - u.InvitationState == UserInvitationState.Pending); + false, u.InvitationState == UserInvitationState.Pending); } \ No newline at end of file diff --git a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs index ddece265..0c88f615 100644 --- a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs +++ b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDtoFactory.cs @@ -230,16 +230,7 @@ private RelatedUserGroupsDto GetRelatedUsers(WorkflowDefinition workflowDefiniti { var value = context.Get(relatedUser.Property); - var users = value switch - { - InstanceUser user => [user], - InstanceUser[] arr => arr, - _ => [] - }; - - // Always emit at least one entry with null user if nothing resolved - if (users.Length == 0) - return [new { relatedUser.Group, Dto = new RelatedUserDto(relatedUser.DisplayTitle, null) }]; + var users = value is InstanceUser u ? [u] : value as InstanceUser[] ?? []; return users.Select(user => new { From 173eec8a8aee0520379559ea473eb88295606145 Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Mon, 22 Jun 2026 14:16:50 +0200 Subject: [PATCH 06/16] DN-3759 Removed duplicate enum --- UvA.Workflow/Users/User.cs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/UvA.Workflow/Users/User.cs b/UvA.Workflow/Users/User.cs index 94b7a7d9..2dc2b618 100644 --- a/UvA.Workflow/Users/User.cs +++ b/UvA.Workflow/Users/User.cs @@ -2,12 +2,6 @@ namespace UvA.Workflow.Users; -public enum UserInvitationState -{ - Required, - Pending -} - /// /// A state to represent a possible invitation for an (external) user. /// From f9e3c09aca1e9b07ee1eb1ee3e3bd7bc42de75a4 Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Thu, 25 Jun 2026 11:46:54 +0200 Subject: [PATCH 07/16] DN-3759 Added api endpoint for editing email addresses of external users --- .../Users/Dtos/UpdateUserEmailDto.cs | 6 + UvA.Workflow.Api/Users/UsersController.cs | 44 ++++- .../Controllers/UsersControllerTests.cs | 157 ++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs diff --git a/UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs b/UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs new file mode 100644 index 00000000..0c22f7ea --- /dev/null +++ b/UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs @@ -0,0 +1,6 @@ +using System.ComponentModel.DataAnnotations; + +namespace UvA.Workflow.Api.Users.Dtos; + +public record UpdateUserEmailDto( + [Required] [EmailAddress] string Email); \ No newline at end of file diff --git a/UvA.Workflow.Api/Users/UsersController.cs b/UvA.Workflow.Api/Users/UsersController.cs index 69716747..b5d779df 100644 --- a/UvA.Workflow.Api/Users/UsersController.cs +++ b/UvA.Workflow.Api/Users/UsersController.cs @@ -87,6 +87,42 @@ public async Task> GetById(string id, CancellationToken ct return Ok(UserDto.Create(user)); } + [HttpPut("{id}/email")] + public async Task> UpdateEmail( + string id, + [FromBody] UpdateUserEmailDto dto, + CancellationToken ct) + { + await rightsService.EnsureAuthorizedForAction(RoleAction.Edit); + + var user = await userRepository.GetById(id, ct); + if (user == null) + return UserNotFound; + + if (!CanUpdateExternalUserEmail(user)) + { + return Unprocessable( + "UserEmailUpdateNotAllowed", + "Email address can only be updated for external users that have not started an invitation"); + } + + var emailValidationResult = await ValidateEmail(dto.Email, ct, user.Id); + if (emailValidationResult != null) + return emailValidationResult; + + var previousEmail = user.Email; + var email = dto.Email.Trim(); + if (string.Equals(user.Email, email, StringComparison.Ordinal)) + return Ok(UserDto.Create(user)); + + user.Email = email; + if (string.Equals(user.UserName, previousEmail, StringComparison.OrdinalIgnoreCase)) + user.UserName = email; + + await userRepository.Update(user, ct); + return Ok(UserDto.Create(user)); + } + [HttpGet("find")] public async Task>> Find(string query, [FromQuery] bool includeExternalUsers = true, CancellationToken ct = default) @@ -95,7 +131,7 @@ public async Task>> Find(string qu return Ok(searchResults.Select(UserSearchResultDto.Create)); } - private async Task ValidateEmail(string email, CancellationToken ct) + private async Task ValidateEmail(string email, CancellationToken ct, string? ignoredUserId = null) { var trimmedEmail = email.Trim(); if (!EmailAddressAttribute.IsValid(trimmedEmail)) @@ -105,9 +141,13 @@ public async Task>> Find(string qu return BadRequest(ManualUserInternalEmailCode, InternalEmailMessage); var existingUser = await userRepository.GetByEmail(trimmedEmail, ct); - if (existingUser != null) + if (existingUser != null && !string.Equals(existingUser.Id, ignoredUserId, StringComparison.Ordinal)) return Conflict(ManualUserEmailAlreadyExistsCode, DuplicateEmailMessage); return null; } + + private static bool CanUpdateExternalUserEmail(User user) + => UserProviderKeys.IsExternal(user.ProviderKey) && + user.InvitationState == UserInvitationState.Required; } \ No newline at end of file diff --git a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs index 12cb1fe6..58f22f28 100644 --- a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs +++ b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs @@ -134,6 +134,163 @@ public async Task Users_Create_TrimsEmailBeforePersisting() Assert.Equal("doctor@amsterdamumc.nl", createdUser.Email); } + [Fact] + public async Task Users_UpdateEmail_UpdatesRequiredExternalUser() + { + var user = new User + { + Id = "external-user-id", + UserName = "old@example.org", + DisplayName = "External User", + Email = "old@example.org", + ProviderKey = "eduid", + InvitationState = UserInvitationState.Required, + IsActive = false + }; + _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); + var controller = BuildControllerWithRoles(["Api"]); + + var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto(" new@example.org "), _ct); + + var okResult = Assert.IsType(result.Result); + var dto = Assert.IsType(okResult.Value); + Assert.Equal("new@example.org", dto.Email); + Assert.Equal("new@example.org", dto.UserName); + _userRepoMock.Verify(r => r.Update(It.Is(u => + u.Id == user.Id && + u.Email == "new@example.org" && + u.UserName == "new@example.org"), _ct), Times.Once); + } + + [Fact] + public async Task Users_UpdateEmail_ReturnsNotFound_WhenUserDoesNotExist() + { + var controller = BuildControllerWithRoles(["Api"]); + + var result = await controller.UpdateEmail("missing-user-id", new UpdateUserEmailDto("new@example.org"), _ct); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status404NotFound, objectResult.StatusCode); + _userRepoMock.Verify(r => r.Update(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("internal", UserInvitationState.Required)] + [InlineData("eduid", UserInvitationState.Pending)] + [InlineData("eduid", UserInvitationState.Completed)] + public async Task Users_UpdateEmail_RejectsUsersThatAreNotEligible( + string providerKey, + UserInvitationState invitationState) + { + var user = new User + { + Id = "user-id", + UserName = "old@example.org", + DisplayName = "User", + Email = "old@example.org", + ProviderKey = providerKey, + InvitationState = invitationState + }; + _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); + var controller = BuildControllerWithRoles(["Api"]); + + var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto("new@example.org"), _ct); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status422UnprocessableEntity, objectResult.StatusCode); + var error = Assert.IsType(objectResult.Value); + Assert.Equal("UserEmailUpdateNotAllowed", error.ErrorCode); + _userRepoMock.Verify(r => r.Update(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Users_UpdateEmail_RejectsDuplicateEmailFromAnotherUser() + { + var user = new User + { + Id = "external-user-id", + UserName = "old@example.org", + DisplayName = "External User", + Email = "old@example.org", + ProviderKey = "eduid", + InvitationState = UserInvitationState.Required + }; + _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); + _userRepoMock.Setup(r => r.GetByEmail("duplicate@example.org", _ct)) + .ReturnsAsync(new User { Id = "other-user-id", Email = "duplicate@example.org" }); + var controller = BuildControllerWithRoles(["Api"]); + + var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto("duplicate@example.org"), _ct); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status409Conflict, objectResult.StatusCode); + var error = Assert.IsType(objectResult.Value); + Assert.Equal("ManualUserEmailAlreadyExists", error.ErrorCode); + _userRepoMock.Verify(r => r.Update(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Users_UpdateEmail_AllowsSameEmailForSameUser() + { + var user = new User + { + Id = "external-user-id", + UserName = "old@example.org", + DisplayName = "External User", + Email = "old@example.org", + ProviderKey = "eduid", + InvitationState = UserInvitationState.Required + }; + _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); + _userRepoMock.Setup(r => r.GetByEmail("old@example.org", _ct)).ReturnsAsync(user); + var controller = BuildControllerWithRoles(["Api"]); + + var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto("old@example.org"), _ct); + + var okResult = Assert.IsType(result.Result); + var dto = Assert.IsType(okResult.Value); + Assert.Equal("old@example.org", dto.Email); + _userRepoMock.Verify(r => r.Update(It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData("student@uva.nl", StatusCodes.Status400BadRequest, "ManualUserInternalEmail")] + [InlineData("not-an-email", StatusCodes.Status400BadRequest, "InvalidEmailAddress")] + public async Task Users_UpdateEmail_RejectsInvalidTargetEmail( + string email, + int expectedStatusCode, + string expectedErrorCode) + { + var user = new User + { + Id = "external-user-id", + UserName = "old@example.org", + DisplayName = "External User", + Email = "old@example.org", + ProviderKey = "eduid", + InvitationState = UserInvitationState.Required + }; + _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); + var controller = BuildControllerWithRoles(["Api"]); + + var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto(email), _ct); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(expectedStatusCode, objectResult.StatusCode); + var error = Assert.IsType(objectResult.Value); + Assert.Equal(expectedErrorCode, error.ErrorCode); + _userRepoMock.Verify(r => r.Update(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Users_UpdateEmail_RequiresViewAdminRights() + { + var controller = BuildControllerWithRoles(["Student"]); + + await Assert.ThrowsAsync(() => + controller.UpdateEmail("external-user-id", new UpdateUserEmailDto("new@example.org"), _ct)); + } + [Theory] [InlineData("student@uva.nl")] [InlineData("student@sub.uva.nl")] From b141771f16b6199b6627ed394464e8e9fb7ef782 Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Thu, 25 Jun 2026 13:14:22 +0200 Subject: [PATCH 08/16] DN-3759 Small fixes for InvitationState being null --- UvA.Workflow.Api/Users/UsersController.cs | 3 ++- UvA.Workflow/Users/InstanceUser.cs | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/UvA.Workflow.Api/Users/UsersController.cs b/UvA.Workflow.Api/Users/UsersController.cs index b5d779df..bf2d0363 100644 --- a/UvA.Workflow.Api/Users/UsersController.cs +++ b/UvA.Workflow.Api/Users/UsersController.cs @@ -141,7 +141,8 @@ public async Task>> Find(string qu return BadRequest(ManualUserInternalEmailCode, InternalEmailMessage); var existingUser = await userRepository.GetByEmail(trimmedEmail, ct); - if (existingUser != null && !string.Equals(existingUser.Id, ignoredUserId, StringComparison.Ordinal)) + if (existingUser != null && + (ignoredUserId == null || !string.Equals(existingUser.Id, ignoredUserId, StringComparison.Ordinal))) return Conflict(ManualUserEmailAlreadyExistsCode, DuplicateEmailMessage); return null; diff --git a/UvA.Workflow/Users/InstanceUser.cs b/UvA.Workflow/Users/InstanceUser.cs index 7a7163b0..13d0dbb9 100644 --- a/UvA.Workflow/Users/InstanceUser.cs +++ b/UvA.Workflow/Users/InstanceUser.cs @@ -23,7 +23,9 @@ public class InstanceUser [BsonElement("IsExternal")] public bool IsExternal { get; set; } - [BsonElement("InvitationState")] public UserInvitationState? InvitationState { get; set; } = null; + [BsonElement("InvitationState")] + [BsonIgnoreIfNull] + public UserInvitationState? InvitationState { get; set; } = null; public static InstanceUser FromUser(User user) => new() { From 7c88f7094232face9c6cca31618ecebeec99b7b4 Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Thu, 25 Jun 2026 15:06:46 +0200 Subject: [PATCH 09/16] DN-3759 Added RelatedUsers and RelatedUserGroups to inheritance flow. --- .../WorkflowInheritanceTests.cs | 78 +++++++++++++++++++ UvA.Workflow/WorkflowModel/Inheritance.cs | 24 ++++++ 2 files changed, 102 insertions(+) create mode 100644 UvA.Workflow.Tests/WorkflowInheritanceTests.cs diff --git a/UvA.Workflow.Tests/WorkflowInheritanceTests.cs b/UvA.Workflow.Tests/WorkflowInheritanceTests.cs new file mode 100644 index 00000000..c9d5d3db --- /dev/null +++ b/UvA.Workflow.Tests/WorkflowInheritanceTests.cs @@ -0,0 +1,78 @@ +namespace UvA.Workflow.Tests; + +public class WorkflowInheritanceTests +{ + [Fact] + public void ProjectRmss_InheritsRelatedUsersAndGroupingFromProject() + { + var parser = new ModelParser(new FileSystemProvider("../../../../Examples/Projects")); + var projectRmss = parser.WorkflowDefinitions["Project-RMSS"]; + + Assert.Contains(projectRmss.RelatedUsers, relatedUser => relatedUser.Property == "Supervisor"); + Assert.Contains(projectRmss.RelatedUsers, relatedUser => relatedUser.Property == "Course.Coordinator"); + Assert.Contains(projectRmss.RelatedUserGrouping!.Groups, group => group.Name == "default"); + Assert.Contains(projectRmss.RelatedUserGrouping.Groups, group => group.Name == "support"); + } + + [Fact] + public void ModelParser_MergesInheritedAndChildRelatedUsersAndGrouping() + { + var parser = new ModelParser(new InheritanceContentProvider()); + var child = parser.WorkflowDefinitions["ChildWorkflow"]; + + Assert.Equal(["Supervisor", "Coordinator", "Reviewer"], + child.RelatedUsers.Select(relatedUser => relatedUser.Property).ToArray()); + Assert.Equal(["default", "support", "review"], + child.RelatedUserGrouping!.Groups.Select(group => group.Name).ToArray()); + } + + private sealed class InheritanceContentProvider : IContentProvider + { + public IEnumerable GetFolders(string? directory = null) + => directory == null ? ["BaseWorkflow", "ChildWorkflow"] : Array.Empty(); + + public IEnumerable GetFiles(string directory) => directory switch + { + "BaseWorkflow" => ["BaseWorkflow/Entity.yaml"], + "ChildWorkflow" => ["ChildWorkflow/Entity.yaml"], + _ => Array.Empty() + }; + + public string GetFile(string file) => file switch + { + "BaseWorkflow/Entity.yaml" => """ + name: BaseWorkflow + titlePlural: Base workflows + properties: + - name: Supervisor + type: User + - name: Coordinator + type: User + relatedUsers: + - property: Supervisor + group: default + - property: Coordinator + group: support + relatedUserGrouping: + groups: + - name: default + - name: support + """, + "ChildWorkflow/Entity.yaml" => """ + name: ChildWorkflow + titlePlural: Child workflows + inheritsFrom: BaseWorkflow + properties: + - name: Reviewer + type: User + relatedUsers: + - property: Reviewer + group: review + relatedUserGrouping: + groups: + - name: review + """, + _ => "" + }; + } +} \ No newline at end of file diff --git a/UvA.Workflow/WorkflowModel/Inheritance.cs b/UvA.Workflow/WorkflowModel/Inheritance.cs index 48b6aa68..a796ef36 100644 --- a/UvA.Workflow/WorkflowModel/Inheritance.cs +++ b/UvA.Workflow/WorkflowModel/Inheritance.cs @@ -57,6 +57,30 @@ private void ApplyInheritance(WorkflowDefinition target, WorkflowDefinition sour target.IsEmbedded = source.IsEmbedded; target.IsAlwaysVisible = source.IsAlwaysVisible; target.Fields = source.Fields.Concat(target.Fields).ToArray(); + target.RelatedUsers = source.RelatedUsers + .Where(sourceRelatedUser => target.RelatedUsers.All(targetRelatedUser => + targetRelatedUser.Property != sourceRelatedUser.Property)) + .Concat(target.RelatedUsers) + .ToArray(); + target.RelatedUserGrouping = MergeRelatedUserGrouping(target.RelatedUserGrouping, source.RelatedUserGrouping); + } + + private static RelatedUserGrouping? MergeRelatedUserGrouping(RelatedUserGrouping? target, + RelatedUserGrouping? source) + { + if (source == null) + return target; + + if (target == null) + return new RelatedUserGrouping { Groups = source.Groups }; + + return new RelatedUserGrouping + { + Groups = source.Groups + .Where(sourceGroup => target.Groups.All(targetGroup => targetGroup.Name != sourceGroup.Name)) + .Concat(target.Groups) + .ToArray() + }; } private void ApplyInheritance(Form target, Form source) From 928d8051e381b5211a04f28659924d6d954303ee Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Fri, 26 Jun 2026 10:29:31 +0200 Subject: [PATCH 10/16] DN-3759 Refactored 'isPending' to 'requiresInvitation' --- UvA.Workflow.Api/Users/Dtos/UserDto.cs | 6 +++--- UvA.Workflow.Tests/Controllers/UsersControllerTests.cs | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/UvA.Workflow.Api/Users/Dtos/UserDto.cs b/UvA.Workflow.Api/Users/Dtos/UserDto.cs index ab42e9ce..3bcee172 100644 --- a/UvA.Workflow.Api/Users/Dtos/UserDto.cs +++ b/UvA.Workflow.Api/Users/Dtos/UserDto.cs @@ -12,7 +12,7 @@ public record UserDto( Organization? Organization, bool IsExternal, bool IsSuperAdmin, - bool IsPending + bool RequiresInvitation ) { /// @@ -31,7 +31,7 @@ public static UserDto Create(User user, bool isSuperAdmin = false) user.Organization, UserProviderKeys.IsExternal(user.ProviderKey), isSuperAdmin, - user.InvitationState == UserInvitationState.Pending + user.InvitationState == UserInvitationState.Required ); } @@ -40,5 +40,5 @@ public static UserDto Create(User user, bool isSuperAdmin = false) /// public static UserDto CreateFromInstanceUser(InstanceUser u) => new(u.Id, u.UserName, u.DisplayName, u.Email, u.PreferredLanguage, u.Organization, u.IsExternal, - false, u.InvitationState == UserInvitationState.Pending); + false, u.InvitationState == UserInvitationState.Required); } \ No newline at end of file diff --git a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs index 58f22f28..b86def73 100644 --- a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs +++ b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs @@ -156,6 +156,7 @@ public async Task Users_UpdateEmail_UpdatesRequiredExternalUser() var dto = Assert.IsType(okResult.Value); Assert.Equal("new@example.org", dto.Email); Assert.Equal("new@example.org", dto.UserName); + Assert.True(dto.RequiresInvitation); _userRepoMock.Verify(r => r.Update(It.Is(u => u.Id == user.Id && u.Email == "new@example.org" && From fe7349b1bc2c549e57ec91d83d0a825fd0de3ab9 Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Fri, 26 Jun 2026 10:56:57 +0200 Subject: [PATCH 11/16] DN-3759 fixed build issue --- UvA.Workflow/WorkflowModel/RelatedUser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UvA.Workflow/WorkflowModel/RelatedUser.cs b/UvA.Workflow/WorkflowModel/RelatedUser.cs index c987bca9..44b254af 100644 --- a/UvA.Workflow/WorkflowModel/RelatedUser.cs +++ b/UvA.Workflow/WorkflowModel/RelatedUser.cs @@ -4,7 +4,7 @@ public class RelatedUser { public string Property { get; set; } = null!; - public string Group { get; set; } + public string Group { get; set; } = null!; public BilingualString? Text { get; set; } = null; From 639a525e5bcf33824984189188279e399f3c768d Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Tue, 30 Jun 2026 11:44:03 +0200 Subject: [PATCH 12/16] DN-3759 Added RelatedUsers preprocessing and moved the translations --- Examples/Projects/Context/Entity.yaml | 16 +++- Examples/Projects/Project/Entity.yaml | 21 ----- Examples/Projects/Project/Properties.yaml | 1 + .../WorkflowInheritanceTests.cs | 80 +++++++++++++++++++ UvA.Workflow/WorkflowModel/ModelParser.cs | 33 ++++++++ 5 files changed, 128 insertions(+), 23 deletions(-) diff --git a/Examples/Projects/Context/Entity.yaml b/Examples/Projects/Context/Entity.yaml index 83bf1b05..4ca9c1bf 100644 --- a/Examples/Projects/Context/Entity.yaml +++ b/Examples/Projects/Context/Entity.yaml @@ -17,15 +17,27 @@ properties: - name: Coordinator type: "[User]!" + text: + en: Coordinator + nl: Coördinator - name: Impersonator type: "[User]!" + text: + en: Impersonator + nl: Impersonator - name: StudyManual type: String! - name: ConfidentialAdviser type: User! - + text: + en: Confidential adviser + nl: Vertrouwenspersoon + - name: StudyAdvisor - type: User! \ No newline at end of file + type: User! + text: + en: Study advisor + nl: Studieadviseur \ No newline at end of file diff --git a/Examples/Projects/Project/Entity.yaml b/Examples/Projects/Project/Entity.yaml index fcbf1f98..4f9dbc71 100644 --- a/Examples/Projects/Project/Entity.yaml +++ b/Examples/Projects/Project/Entity.yaml @@ -26,39 +26,18 @@ steps: relatedUsers: - property: Supervisor group: default - text: - en: Supervisor - nl: Begeleider - property: Examiner group: default - text: - en: Examiner - nl: Examinator - property: Reviewer group: default - text: - en: Reviewer - nl: Beoordelaar - property: SecondReader group: default - text: - en: Second reader - nl: 2e lezer - property: Course.Coordinator group: support - text: - en: Coordinator - nl: Coördinator - property: Course.ConfidentialAdviser group: support - text: - en: Confidential adviser - nl: Vertrouwenspersoon - property: Course.StudyAdvisor group: support - text: - en: Study advisor - nl: Studieadviseur relatedUserGrouping: groups: diff --git a/Examples/Projects/Project/Properties.yaml b/Examples/Projects/Project/Properties.yaml index abd7af66..69bf03c2 100644 --- a/Examples/Projects/Project/Properties.yaml +++ b/Examples/Projects/Project/Properties.yaml @@ -27,6 +27,7 @@ properties: text: en: Examiner nl: Examinator + - name: Supervisor type: User! allowsExternalUsers: true diff --git a/UvA.Workflow.Tests/WorkflowInheritanceTests.cs b/UvA.Workflow.Tests/WorkflowInheritanceTests.cs index c9d5d3db..6fa19030 100644 --- a/UvA.Workflow.Tests/WorkflowInheritanceTests.cs +++ b/UvA.Workflow.Tests/WorkflowInheritanceTests.cs @@ -26,6 +26,29 @@ public void ModelParser_MergesInheritedAndChildRelatedUsersAndGrouping() child.RelatedUserGrouping!.Groups.Select(group => group.Name).ToArray()); } + [Fact] + public void ModelParser_UsesRelatedUserPropertyDisplayNameAsDefaultTitle() + { + var parser = new ModelParser(new RelatedUserTitleContentProvider()); + var workflow = parser.WorkflowDefinitions["Project"]; + + var supervisor = workflow.RelatedUsers.Single(relatedUser => relatedUser.Property == "Supervisor"); + Assert.Equal("Supervisor", supervisor.DisplayTitle.En); + Assert.Equal("Begeleider", supervisor.DisplayTitle.Nl); + + var coordinator = workflow.RelatedUsers.Single(relatedUser => relatedUser.Property == "Course.Coordinator"); + Assert.Equal("Coordinator", coordinator.DisplayTitle.En); + Assert.Equal("Coordinator NL", coordinator.DisplayTitle.Nl); + + var reviewer = workflow.RelatedUsers.Single(relatedUser => relatedUser.Property == "Reviewer"); + Assert.Equal("Configured reviewer", reviewer.DisplayTitle.En); + Assert.Equal("Geconfigureerde beoordelaar", reviewer.DisplayTitle.Nl); + + var missing = workflow.RelatedUsers.Single(relatedUser => relatedUser.Property == "MissingUser"); + Assert.Equal("MissingUser", missing.DisplayTitle.En); + Assert.Equal("MissingUser", missing.DisplayTitle.Nl); + } + private sealed class InheritanceContentProvider : IContentProvider { public IEnumerable GetFolders(string? directory = null) @@ -75,4 +98,61 @@ public IEnumerable GetFolders(string? directory = null) _ => "" }; } + + private sealed class RelatedUserTitleContentProvider : IContentProvider + { + public IEnumerable GetFolders(string? directory = null) + => directory == null ? ["Context", "Project"] : Array.Empty(); + + public IEnumerable GetFiles(string directory) => directory switch + { + "Context" => ["Context/Entity.yaml"], + "Project" => ["Project/Entity.yaml"], + _ => Array.Empty() + }; + + public string GetFile(string file) => file switch + { + "Context/Entity.yaml" => """ + name: Context + titlePlural: Contexts + properties: + - name: Coordinator + type: User + text: + en: Coordinator + nl: Coordinator NL + """, + "Project/Entity.yaml" => """ + name: Project + titlePlural: Projects + properties: + - name: Course + type: Context! + - name: Supervisor + type: User + text: + en: Supervisor + nl: Begeleider + - name: Reviewer + type: User + text: + en: Reviewer property + nl: Beoordelaar property + relatedUsers: + - property: Supervisor + group: default + - property: Course.Coordinator + group: default + - property: Reviewer + group: default + text: + en: Configured reviewer + nl: Geconfigureerde beoordelaar + - property: MissingUser + group: default + """, + _ => "" + }; + } } \ No newline at end of file diff --git a/UvA.Workflow/WorkflowModel/ModelParser.cs b/UvA.Workflow/WorkflowModel/ModelParser.cs index 7792aa63..fc7b6184 100644 --- a/UvA.Workflow/WorkflowModel/ModelParser.cs +++ b/UvA.Workflow/WorkflowModel/ModelParser.cs @@ -234,6 +234,8 @@ private void PreProcess(WorkflowDefinition workflowDefinition) PreProcess(step, workflowDefinition); foreach (var field in workflowDefinition.Fields) PreProcess(field, workflowDefinition); + foreach (var relatedUser in workflowDefinition.RelatedUsers) + PreProcess(relatedUser, workflowDefinition); foreach (var form in workflowDefinition.Forms) ValidateSubmittedWhenEvents(form, workflowDefinition); @@ -307,6 +309,37 @@ private void PreProcess(Field field, WorkflowDefinition workflowDefinition) field.PropertyDefinition = workflowDefinition.Properties.GetOrDefault(field.Property); } + private void PreProcess(RelatedUser relatedUser, WorkflowDefinition workflowDefinition) + { + relatedUser.PropertyDefinition = ResolvePropertyDefinition(workflowDefinition, relatedUser.Property); + } + + private static PropertyDefinition? ResolvePropertyDefinition(WorkflowDefinition workflowDefinition, + string propertyPath) + { + var type = workflowDefinition; + var parts = propertyPath.Split('.'); + PropertyDefinition? property = null; + + for (var i = 0; i < parts.Length; i++) + { + var part = parts[i]; + property = type.Properties.GetOrDefault(part); + if (property == null) + return null; + + if (i < parts.Length - 1) + { + if (property.WorkflowDefinition == null) + return null; + + type = property.WorkflowDefinition; + } + } + + return property; + } + private void PreProcess(Screen screen, WorkflowDefinition workflowDefinition) { foreach (var col in screen.Columns) From 6c13dde00f04ef7a7b530a1dc89c20e53ef7f6f4 Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Wed, 1 Jul 2026 15:10:32 +0200 Subject: [PATCH 13/16] DN-3759 Renamed Adviser to Advisor --- Examples/Projects/Context/Entity.yaml | 6 +++--- Examples/Projects/Project/Entity.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Examples/Projects/Context/Entity.yaml b/Examples/Projects/Context/Entity.yaml index 4ca9c1bf..a55ec098 100644 --- a/Examples/Projects/Context/Entity.yaml +++ b/Examples/Projects/Context/Entity.yaml @@ -30,14 +30,14 @@ properties: - name: StudyManual type: String! - - name: ConfidentialAdviser + - name: ConfidentialAdvisor type: User! text: - en: Confidential adviser + en: Confidential advisor nl: Vertrouwenspersoon - name: StudyAdvisor type: User! text: en: Study advisor - nl: Studieadviseur \ No newline at end of file + nl: Studieadviseur diff --git a/Examples/Projects/Project/Entity.yaml b/Examples/Projects/Project/Entity.yaml index 4f9dbc71..99de96f2 100644 --- a/Examples/Projects/Project/Entity.yaml +++ b/Examples/Projects/Project/Entity.yaml @@ -34,7 +34,7 @@ relatedUsers: group: default - property: Course.Coordinator group: support - - property: Course.ConfidentialAdviser + - property: Course.ConfidentialAdvisor group: support - property: Course.StudyAdvisor group: support From c6e2d4111a5bc46c706c6dcb010b2af6aedef821 Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Thu, 2 Jul 2026 13:47:12 +0200 Subject: [PATCH 14/16] DN-3759 Added separate service for updating "external user" emails --- .../ServiceCollectionExtensions.cs | 2 + .../Users/Dtos/UpdateUserEmailDto.cs | 3 +- .../Users/ExternalUserEmailUpdateService.cs | 135 ++++++++++++++ UvA.Workflow.Api/Users/UsersController.cs | 39 +++-- .../Controllers/UsersControllerTests.cs | 164 +++++++++++++++--- 5 files changed, 310 insertions(+), 33 deletions(-) create mode 100644 UvA.Workflow.Api/Users/ExternalUserEmailUpdateService.cs diff --git a/UvA.Workflow.Api/Infrastructure/ServiceCollectionExtensions.cs b/UvA.Workflow.Api/Infrastructure/ServiceCollectionExtensions.cs index 475c3e5e..f83c400a 100644 --- a/UvA.Workflow.Api/Infrastructure/ServiceCollectionExtensions.cs +++ b/UvA.Workflow.Api/Infrastructure/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using UvA.Workflow.Api.Assessments.Dtos; using UvA.Workflow.Api.Screens; using UvA.Workflow.Api.Submissions.Dtos; +using UvA.Workflow.Api.Users; using UvA.Workflow.Api.WorkflowInstances; using UvA.Workflow.Api.WorkflowInstances.Dtos; @@ -26,6 +27,7 @@ public static IServiceCollection AddWorkflowApiCore(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); + services.AddScoped(); return services; } diff --git a/UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs b/UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs index 0c22f7ea..e45161df 100644 --- a/UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs +++ b/UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs @@ -3,4 +3,5 @@ namespace UvA.Workflow.Api.Users.Dtos; public record UpdateUserEmailDto( - [Required] [EmailAddress] string Email); \ No newline at end of file + [Required] [EmailAddress] string Email, + [Required] string InstanceId); \ No newline at end of file diff --git a/UvA.Workflow.Api/Users/ExternalUserEmailUpdateService.cs b/UvA.Workflow.Api/Users/ExternalUserEmailUpdateService.cs new file mode 100644 index 00000000..9b4be908 --- /dev/null +++ b/UvA.Workflow.Api/Users/ExternalUserEmailUpdateService.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using UvA.Workflow.Submissions; + +namespace UvA.Workflow.Api.Users; + +public enum ExternalUserEmailAnswerUpdateResult +{ + Updated, + UserNotInAnswer, + Forbidden +} + +public record ExternalUserEmailAnswerUpdatePlan( + ExternalUserEmailAnswerUpdateResult Result, + IReadOnlyCollection EditableContexts); + +public class ExternalUserEmailUpdateService( + RightsService rightsService, + AnswerService answerService, + ModelService modelService) +{ + public static bool CanUpdateExternalUserEmail(User user) + => UserProviderKeys.IsExternal(user.ProviderKey) && + user.InvitationState == UserInvitationState.Required; + + public async Task PrepareAnswerReferenceUpdate( + WorkflowInstance instance, + User user, + CancellationToken ct) + { + var contexts = GetMatchingUserQuestionContexts(instance, user.Id).ToArray(); + if (contexts.Length == 0) + return new ExternalUserEmailAnswerUpdatePlan( + ExternalUserEmailAnswerUpdateResult.UserNotInAnswer, + []); + + var editableContexts = new List(); + foreach (var context in contexts) + { + if (await CanEdit(context)) + editableContexts.Add(context); + } + + if (editableContexts.Count == 0) + return new ExternalUserEmailAnswerUpdatePlan( + ExternalUserEmailAnswerUpdateResult.Forbidden, + []); + + return new ExternalUserEmailAnswerUpdatePlan( + ExternalUserEmailAnswerUpdateResult.Updated, + editableContexts); + } + + public async Task UpdateAnswerReferences( + ExternalUserEmailAnswerUpdatePlan plan, + User user, + CancellationToken ct) + { + foreach (var context in plan.EditableContexts) + { + if (!TryCreateUpdatedUserAnswerValue(context, user, out var updatedAnswerValue)) + continue; + + await answerService.SaveAnswer(context, updatedAnswerValue, ct); + } + } + + private IEnumerable GetMatchingUserQuestionContexts(WorkflowInstance instance, string userId) + { + var workflowDefinition = modelService.WorkflowDefinitions[instance.WorkflowDefinition]; + foreach (var form in workflowDefinition.Forms) + { + var submissionState = FormSubmissionState.Resolve(instance, form, workflowDefinition); + foreach (var question in form.PropertyDefinitions.Where(q => q.DataType == DataType.User)) + { + var context = new QuestionContext(instance, submissionState, form, question); + if (ContainsUserAnswerValue(context, userId)) + yield return context; + } + } + } + + private static bool ContainsUserAnswerValue(QuestionContext context, string userId) + { + var currentAnswer = context.Instance.GetProperty(context.Form.PropertyName, context.PropertyDefinition.Name); + if (currentAnswer == null || currentAnswer.IsBsonNull) + return false; + + if (context.PropertyDefinition.IsArray) + { + var users = ObjectContext.GetValue(currentAnswer, context.PropertyDefinition) as InstanceUser[]; + return users?.Any(u => u.Id == userId) == true; + } + + var answerUser = ObjectContext.GetValue(currentAnswer, context.PropertyDefinition) as InstanceUser; + return answerUser?.Id == userId; + } + + private static bool TryCreateUpdatedUserAnswerValue( + QuestionContext context, + User user, + out JsonElement? value) + { + var currentAnswer = context.Instance.GetProperty(context.Form.PropertyName, context.PropertyDefinition.Name); + value = null; + if (currentAnswer == null || currentAnswer.IsBsonNull) + return false; + + var updatedUser = InstanceUser.FromUser(user); + if (context.PropertyDefinition.IsArray) + { + var users = ObjectContext.GetValue(currentAnswer, context.PropertyDefinition) as InstanceUser[]; + if (users == null || users.All(u => u.Id != user.Id)) + return false; + + value = JsonSerializer.SerializeToElement( + users.Select(u => u.Id == user.Id ? updatedUser : u).ToArray(), + AnswerConversionService.Options); + return true; + } + + var answerUser = ObjectContext.GetValue(currentAnswer, context.PropertyDefinition) as InstanceUser; + if (answerUser?.Id != user.Id) + return false; + + value = JsonSerializer.SerializeToElement(updatedUser, AnswerConversionService.Options); + return true; + } + + private async Task CanEdit(QuestionContext context) => + await rightsService.Can(context.Instance, + [context.SubmissionState.IsSubmitted ? RoleAction.Edit : RoleAction.Submit], + RightsEvaluationMode.RequestContext, + context.Form.Name); +} \ No newline at end of file diff --git a/UvA.Workflow.Api/Users/UsersController.cs b/UvA.Workflow.Api/Users/UsersController.cs index 33c857f8..ba50b02a 100644 --- a/UvA.Workflow.Api/Users/UsersController.cs +++ b/UvA.Workflow.Api/Users/UsersController.cs @@ -11,10 +11,12 @@ namespace UvA.Workflow.Api.Users; public class UsersController( IUserService userService, IUserRepository userRepository, + IWorkflowInstanceRepository workflowInstanceRepository, RightsService rightsService, IEduIdUserService eduIdUserService, HttpContextCurrentUserAccessor realUserAccessor, UserImpersonationTokenService userImpersonationTokenService, + ExternalUserEmailUpdateService externalUserEmailUpdateService, ILogger logger) : ApiControllerBase { private const string ValidEmailStatus = "Valid"; @@ -22,6 +24,7 @@ public class UsersController( private const string ManualUserEmailAlreadyExistsCode = "ManualUserEmailAlreadyExists"; private const string InvalidEmailAddressCode = "InvalidEmailAddress"; private const string ImpersonationTargetNotFoundCode = "ImpersonationTargetNotFound"; + private const string UserNotInAnswerCode = "UserNotInAnswer"; private const string InternalEmailMessage = "Internal email address"; private const string DuplicateEmailMessage = "Email already exists"; @@ -100,26 +103,41 @@ public async Task> UpdateEmail( [FromBody] UpdateUserEmailDto dto, CancellationToken ct) { - await rightsService.EnsureAuthorizedForAction(RoleAction.Edit); + var instance = await workflowInstanceRepository.GetById(dto.InstanceId, ct); + if (instance == null) + return WorkflowInstanceNotFound; var user = await userRepository.GetById(id, ct); if (user == null) return UserNotFound; - if (!CanUpdateExternalUserEmail(user)) + if (!ExternalUserEmailUpdateService.CanUpdateExternalUserEmail(user)) { return Unprocessable( "UserEmailUpdateNotAllowed", "Email address can only be updated for external users that have not started an invitation"); } - var emailValidationResult = await ValidateEmail(dto.Email, ct, user.Id); - if (emailValidationResult != null) - return emailValidationResult; + var updatePlan = await externalUserEmailUpdateService.PrepareAnswerReferenceUpdate(instance, user, ct); + switch (updatePlan.Result) + { + case ExternalUserEmailAnswerUpdateResult.UserNotInAnswer: + return Unprocessable(UserNotInAnswerCode, UserNotInAnswerCode); + case ExternalUserEmailAnswerUpdateResult.Forbidden: + return Forbidden(); + } + + if (user.Email != dto.Email) + { + var emailValidationResult = await ValidateEmail(dto.Email, ct); + if (emailValidationResult != null) + return emailValidationResult; + } var previousEmail = user.Email; var email = dto.Email.Trim(); - if (string.Equals(user.Email, email, StringComparison.Ordinal)) + var userChanged = !string.Equals(user.Email, email, StringComparison.Ordinal); + if (!userChanged) return Ok(UserDto.Create(user)); user.Email = email; @@ -127,6 +145,8 @@ public async Task> UpdateEmail( user.UserName = email; await userRepository.Update(user, ct); + await externalUserEmailUpdateService.UpdateAnswerReferences(updatePlan, user, ct); + return Ok(UserDto.Create(user)); } @@ -177,14 +197,9 @@ public async Task> Impersonate( return BadRequest(ManualUserInternalEmailCode, InternalEmailMessage); var existingUser = await userRepository.GetByEmail(trimmedEmail, ct); - if (existingUser != null && - (ignoredUserId == null || !string.Equals(existingUser.Id, ignoredUserId, StringComparison.Ordinal))) + if (existingUser != null) return Conflict(ManualUserEmailAlreadyExistsCode, DuplicateEmailMessage); return null; } - - private static bool CanUpdateExternalUserEmail(User user) - => UserProviderKeys.IsExternal(user.ProviderKey) && - user.InvitationState == UserInvitationState.Required; } \ No newline at end of file diff --git a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs index 19ac9c19..13a6ff36 100644 --- a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs +++ b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs @@ -1,18 +1,47 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; +using MongoDB.Bson; +using MongoDB.Driver; using UvA.Workflow.Api.Infrastructure; using UvA.Workflow.Api.Users; using UvA.Workflow.Api.Users.Dtos; using UvA.Workflow.Organizations; +using UvA.Workflow.Submissions; using UvA.Workflow.Tests.Controllers.Helpers; using UvA.Workflow.Tests.Helpers; using UvA.Workflow.Users; +using UvA.Workflow.WorkflowInstances; namespace UvA.Workflow.Tests.Controllers; public class UsersControllerTests : ControllerTestsBase { + private const string InstanceId = "instance-id"; + private const string ExternalUserId = "665f35fb3f1b3c6d4b3d0f12"; + + private readonly AnswerService _answerService; + private readonly ExternalUserEmailUpdateService _externalUserEmailUpdateService; + + public UsersControllerTests() + { + _answerService = new AnswerService( + new SubmissionService(_workflowInstanceRepoMock.Object, _modelService, _instanceService, + _instanceJournalServiceMock.Object, _workflowInstanceService, _jobService, _effectService), + _modelService, + _instanceService, + _rightsService, + _artifactServiceMock.Object, + new AnswerConversionService(_userServiceMock.Object, _userRepoMock.Object), + _instanceEventService.Object, + _instanceJournalServiceMock.Object, + _userServiceMock.Object); + _externalUserEmailUpdateService = new ExternalUserEmailUpdateService( + _rightsService, + _answerService, + _modelService); + } + [Theory] [InlineData("Coordinator")] [InlineData("Student")] @@ -139,7 +168,7 @@ public async Task Users_UpdateEmail_UpdatesRequiredExternalUser() { var user = new User { - Id = "external-user-id", + Id = ExternalUserId, UserName = "old@example.org", DisplayName = "External User", Email = "old@example.org", @@ -149,8 +178,11 @@ public async Task Users_UpdateEmail_UpdatesRequiredExternalUser() }; _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); var controller = BuildControllerWithRoles(["Api"]); + _userServiceMock.Setup(s => s.GetUser("new@example.org", It.IsAny())) + .ReturnsAsync(user); - var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto(" new@example.org "), _ct); + var result = await controller.UpdateEmail(user.Id, + new UpdateUserEmailDto(" new@example.org ", InstanceId), _ct); var okResult = Assert.IsType(result.Result); var dto = Assert.IsType(okResult.Value); @@ -163,12 +195,63 @@ public async Task Users_UpdateEmail_UpdatesRequiredExternalUser() u.UserName == "new@example.org"), _ct), Times.Once); } + [Fact] + public async Task Users_UpdateEmail_UpdatesEveryEditableUserReferenceInInstance() + { + var userId = ObjectId.GenerateNewId().ToString(); + var user = new User + { + Id = userId, + UserName = "old@example.org", + DisplayName = "External User", + Email = "old@example.org", + ProviderKey = "eduid", + InvitationState = UserInvitationState.Required, + IsActive = false + }; + var instance = new WorkflowInstanceBuilder() + .With(workflowDefinition: "Project", currentStep: "Start", id: InstanceId) + .WithProperties( + ("Supervisor", _ => new BsonDocument + { + { "_id", ObjectId.Parse(userId) }, + { "UserName", "old@example.org" }, + { "DisplayName", "External User" }, + { "Email", "old@example.org" } + }), + ("Reviewer", _ => new BsonDocument + { + { "_id", ObjectId.Parse(userId) }, + { "UserName", "old@example.org" }, + { "DisplayName", "External User" }, + { "Email", "old@example.org" } + })) + .WithEvent("Start", DateTime.UtcNow) + .Build(); + _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); + var controller = BuildControllerWithRoles(["Api"]); + _workflowInstanceRepoMock.Setup(r => r.GetById(InstanceId, _ct)).ReturnsAsync(instance); + _userServiceMock.Setup(s => s.GetUser("new@example.org", It.IsAny())) + .ReturnsAsync(user); + + await controller.UpdateEmail(user.Id, + new UpdateUserEmailDto("new@example.org", InstanceId), _ct); + + Assert.Equal("new@example.org", instance.Properties["Supervisor"].AsBsonDocument["Email"].AsString); + Assert.Equal("new@example.org", instance.Properties["Supervisor"].AsBsonDocument["UserName"].AsString); + Assert.Equal("new@example.org", instance.Properties["Reviewer"].AsBsonDocument["Email"].AsString); + Assert.Equal("new@example.org", instance.Properties["Reviewer"].AsBsonDocument["UserName"].AsString); + _workflowInstanceRepoMock.Verify(r => r.UpdateFields(instance.Id, + It.IsAny>(), _ct), Times.Exactly(2)); + } + [Fact] public async Task Users_UpdateEmail_ReturnsNotFound_WhenUserDoesNotExist() { var controller = BuildControllerWithRoles(["Api"]); - var result = await controller.UpdateEmail("missing-user-id", new UpdateUserEmailDto("new@example.org"), _ct); + var result = await controller.UpdateEmail("missing-user-id", + new UpdateUserEmailDto("new@example.org", InstanceId), _ct); var objectResult = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status404NotFound, objectResult.StatusCode); @@ -195,7 +278,8 @@ public async Task Users_UpdateEmail_RejectsUsersThatAreNotEligible( _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); var controller = BuildControllerWithRoles(["Api"]); - var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto("new@example.org"), _ct); + var result = await controller.UpdateEmail(user.Id, + new UpdateUserEmailDto("new@example.org", InstanceId), _ct); var objectResult = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status422UnprocessableEntity, objectResult.StatusCode); @@ -209,7 +293,7 @@ public async Task Users_UpdateEmail_RejectsDuplicateEmailFromAnotherUser() { var user = new User { - Id = "external-user-id", + Id = ExternalUserId, UserName = "old@example.org", DisplayName = "External User", Email = "old@example.org", @@ -221,7 +305,8 @@ public async Task Users_UpdateEmail_RejectsDuplicateEmailFromAnotherUser() .ReturnsAsync(new User { Id = "other-user-id", Email = "duplicate@example.org" }); var controller = BuildControllerWithRoles(["Api"]); - var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto("duplicate@example.org"), _ct); + var result = await controller.UpdateEmail(user.Id, + new UpdateUserEmailDto("duplicate@example.org", InstanceId), _ct); var objectResult = Assert.IsType(result.Result); Assert.Equal(StatusCodes.Status409Conflict, objectResult.StatusCode); @@ -235,7 +320,7 @@ public async Task Users_UpdateEmail_AllowsSameEmailForSameUser() { var user = new User { - Id = "external-user-id", + Id = ExternalUserId, UserName = "old@example.org", DisplayName = "External User", Email = "old@example.org", @@ -246,12 +331,15 @@ public async Task Users_UpdateEmail_AllowsSameEmailForSameUser() _userRepoMock.Setup(r => r.GetByEmail("old@example.org", _ct)).ReturnsAsync(user); var controller = BuildControllerWithRoles(["Api"]); - var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto("old@example.org"), _ct); + var result = await controller.UpdateEmail(user.Id, + new UpdateUserEmailDto("old@example.org", InstanceId), _ct); var okResult = Assert.IsType(result.Result); var dto = Assert.IsType(okResult.Value); Assert.Equal("old@example.org", dto.Email); _userRepoMock.Verify(r => r.Update(It.IsAny(), It.IsAny()), Times.Never); + _workflowInstanceRepoMock.Verify(r => r.UpdateFields(It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); } [Theory] @@ -264,7 +352,7 @@ public async Task Users_UpdateEmail_RejectsInvalidTargetEmail( { var user = new User { - Id = "external-user-id", + Id = ExternalUserId, UserName = "old@example.org", DisplayName = "External User", Email = "old@example.org", @@ -274,7 +362,8 @@ public async Task Users_UpdateEmail_RejectsInvalidTargetEmail( _userRepoMock.Setup(r => r.GetById(user.Id, _ct)).ReturnsAsync(user); var controller = BuildControllerWithRoles(["Api"]); - var result = await controller.UpdateEmail(user.Id, new UpdateUserEmailDto(email), _ct); + var result = await controller.UpdateEmail(user.Id, + new UpdateUserEmailDto(email, InstanceId), _ct); var objectResult = Assert.IsType(result.Result); Assert.Equal(expectedStatusCode, objectResult.StatusCode); @@ -284,12 +373,24 @@ public async Task Users_UpdateEmail_RejectsInvalidTargetEmail( } [Fact] - public async Task Users_UpdateEmail_RequiresViewAdminRights() + public async Task Users_UpdateEmail_RequiresAnswerEditRights() { - var controller = BuildControllerWithRoles(["Student"]); + _userRepoMock.Setup(r => r.GetById(ExternalUserId, _ct)).ReturnsAsync(new User + { + Id = ExternalUserId, + UserName = "old@example.org", + DisplayName = "External User", + Email = "old@example.org", + ProviderKey = "eduid", + InvitationState = UserInvitationState.Required + }); + var controller = BuildControllerWithRoles(["HasNoRights"]); - await Assert.ThrowsAsync(() => - controller.UpdateEmail("external-user-id", new UpdateUserEmailDto("new@example.org"), _ct)); + var result = await controller.UpdateEmail(ExternalUserId, + new UpdateUserEmailDto("new@example.org", InstanceId), _ct); + + var objectResult = Assert.IsType(result.Result); + Assert.Equal(StatusCodes.Status403Forbidden, objectResult.StatusCode); } [Theory] @@ -381,9 +482,8 @@ public async Task Users_GetLoggedInUser_ReturnsNotFound_WhenNoUserIsAuthenticate _userRepoMock.Object, _rightsService, _eduIdUserServiceMock.Object, - null!, - null!, - null!); + _workflowInstanceRepoMock.Object, + _externalUserEmailUpdateService); var result = await controller.GetLoggedInUser(_ct); @@ -496,14 +596,38 @@ private UsersController BuildControllerWithRoles( MockCurrentUser(roles); _eduIdUserServiceMock.Setup(s => s.IsInternalEmailAddress(It.IsAny())) .Returns((string email) => IsConfiguredInternalEmail(email)); + _workflowInstanceRepoMock.Setup(r => r.GetById(InstanceId, _ct)) + .ReturnsAsync(new WorkflowInstanceBuilder() + .With(workflowDefinition: "Project", currentStep: "Start", id: InstanceId) + .WithProperties(("Supervisor", _ => new BsonDocument + { + { "_id", ObjectId.Parse(ExternalUserId) }, + { "UserName", "old@example.org" }, + { "DisplayName", "External User" }, + { "Email", "old@example.org" } + })) + .WithEvent("Start", DateTime.UtcNow) + .Build()); + _workflowInstanceRepoMock.Setup(r => r.UpdateFields(InstanceId, + It.IsAny>(), _ct)) + .Returns(Task.CompletedTask); + _userServiceMock.Setup(s => s.GetUser(It.IsAny(), It.IsAny())) + .ReturnsAsync((string userName, CancellationToken _) => new User + { + Id = ExternalUserId, + UserName = userName, + DisplayName = "External User", + Email = userName, + ProviderKey = "eduid", + InvitationState = UserInvitationState.Required + }); return new UsersController(_userServiceMock.Object, _userRepoMock.Object, _rightsService, _eduIdUserServiceMock.Object, - null!, - null!, - null!); + _workflowInstanceRepoMock.Object, + _externalUserEmailUpdateService); } private static bool IsConfiguredInternalEmail(string email) From 2139138ff43ef7bfc4dd7bfb44453246633c8b72 Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Thu, 2 Jul 2026 15:14:16 +0200 Subject: [PATCH 15/16] DN-3759 Added update to instance data after external user invitations --- UvA.Workflow/Users/InstanceUser.cs | 1 + .../WorkflowInstances/EffectService.cs | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/UvA.Workflow/Users/InstanceUser.cs b/UvA.Workflow/Users/InstanceUser.cs index 13d0dbb9..805dae33 100644 --- a/UvA.Workflow/Users/InstanceUser.cs +++ b/UvA.Workflow/Users/InstanceUser.cs @@ -25,6 +25,7 @@ public class InstanceUser [BsonElement("InvitationState")] [BsonIgnoreIfNull] + [BsonRepresentation(BsonType.String)] public UserInvitationState? InvitationState { get; set; } = null; public static InstanceUser FromUser(User user) => new() diff --git a/UvA.Workflow/WorkflowInstances/EffectService.cs b/UvA.Workflow/WorkflowInstances/EffectService.cs index bfa5856b..ff8b9dbf 100644 --- a/UvA.Workflow/WorkflowInstances/EffectService.cs +++ b/UvA.Workflow/WorkflowInstances/EffectService.cs @@ -171,6 +171,7 @@ private async Task EnsureExternalAccounts( .Select(r => (Recipient: r, Email: r.Email?.Trim() ?? string.Empty)) .DistinctBy(r => r.Email, StringComparer.OrdinalIgnoreCase); + var updatedExternalUsers = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (recipient, email) in normalizedRecipients) { try @@ -184,12 +185,46 @@ private async Task EnsureExternalAccounts( ex); } - _ = await eduIdUserService.EnsureExternalAccount( + var result = await eduIdUserService.EnsureExternalAccount( email, recipient.DisplayName, EduIdInviteDeliveryMode.SendEmail, ct); + if (result.User != null) + updatedExternalUsers[email] = InstanceUser.FromUser(result.User); } + + if (updatedExternalUsers.Count == 0) + return; + + UpdateInstanceUserProperties(instance, property, rawValue, updatedExternalUsers); + await instanceService.SaveValue(instance, null, property.Name, ct); + } + + private static void UpdateInstanceUserProperties( + WorkflowInstance instance, + PropertyDefinition property, + BsonValue rawValue, + IReadOnlyDictionary updatedRecipientsByEmail) + { + if (property.IsArray) + { + var users = ObjectContext.GetValue(rawValue, property) as InstanceUser[]; + if (users == null) return; + + instance.Properties[property.Name] = new BsonArray(users.Select(user => + updatedRecipientsByEmail.TryGetValue(user.Email?.Trim() ?? string.Empty, out var updatedUser) + ? updatedUser.ToBsonDocument() + : user.ToBsonDocument())); + return; + } + + var singleUser = ObjectContext.GetValue(rawValue, property) as InstanceUser; + if (singleUser == null || + !updatedRecipientsByEmail.TryGetValue(singleUser.Email?.Trim() ?? string.Empty, out var updatedSingleUser)) + return; + + instance.Properties[property.Name] = updatedSingleUser.ToBsonDocument(); } private async Task ServiceCall(WorkflowInstance instance, ObjectContext context, Effect effect, From c103e156efedd1876ac2848aadc04a7f398ef91b Mon Sep 17 00:00:00 2001 From: Michel Roos Date: Thu, 2 Jul 2026 15:14:31 +0200 Subject: [PATCH 16/16] DN-3759 Unit test updates --- .../Controllers/ActionsControllerTests.cs | 27 +++++++++++++++++-- .../Controllers/UsersControllerTests.cs | 15 ++++++++--- .../UsersControllerImpersonationTests.cs | 2 ++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/UvA.Workflow.Tests/Controllers/ActionsControllerTests.cs b/UvA.Workflow.Tests/Controllers/ActionsControllerTests.cs index ab7ff086..b346de75 100644 --- a/UvA.Workflow.Tests/Controllers/ActionsControllerTests.cs +++ b/UvA.Workflow.Tests/Controllers/ActionsControllerTests.cs @@ -1,6 +1,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; using Moq; using UvA.Workflow.Api.Actions; using UvA.Workflow.Api.Actions.Dtos; @@ -133,14 +136,28 @@ public async Task Actions_ExecuteAction_ReturnsBadRequest_WhenActionNameMissing( public async Task Actions_ExecuteAction_CreateExternalSupervisorAccount_RunsEffectAndLogsEvent() { var (controller, instance) = BuildControllerWithRoles(["Coordinator"], "ApprovalCoordinator"); + var supervisorId = ObjectId.GenerateNewId().ToString(); instance.Properties["Supervisor"] = - new PropertyBuilder().Person("External Supervisor", "supervisor@external.org"); + new PropertyBuilder().Person("External Supervisor", "supervisor@external.org", objectId: supervisorId); + var invitedSupervisor = new User + { + Id = supervisorId, + UserName = "supervisor@external.org", + DisplayName = "External Supervisor", + Email = "supervisor@external.org", + ProviderKey = "eduid", + InvitationState = UserInvitationState.Pending + }; _eduIdUserServiceMock.Setup(s => s.EnsureExternalAccount( "supervisor@external.org", "External Supervisor", EduIdInviteDeliveryMode.SendEmail, _ct)) - .ReturnsAsync(new EduIdExternalAccountResult(EduIdExternalAccountStatus.Invited)); + .ReturnsAsync(new EduIdExternalAccountResult(EduIdExternalAccountStatus.Invited, invitedSupervisor)); + _workflowInstanceRepoMock.Setup(r => r.UpdateFields(instance.Id, + It.IsAny>(), + _ct)) + .Returns(Task.CompletedTask); var result = await controller.ExecuteAction( new ExecuteActionInputDto(ActionType.Execute, instance.Id, "CoordinatorApproved"), @@ -148,7 +165,13 @@ public async Task Actions_ExecuteAction_CreateExternalSupervisorAccount_RunsEffe var okResult = Assert.IsType(result.Result); Assert.IsType(okResult.Value); + var supervisor = BsonSerializer.Deserialize(instance.Properties["Supervisor"].AsBsonDocument); + Assert.Equal(UserInvitationState.Pending, supervisor.InvitationState); + Assert.True(supervisor.IsExternal); _eduIdUserServiceMock.VerifyAll(); + _workflowInstanceRepoMock.Verify(r => r.UpdateFields(instance.Id, + It.IsAny>(), + _ct), Times.Once); _eventRepoMock.Verify(r => r.AddOrUpdateEvent(instance, It.Is(e => e.Id == "CoordinatorApproved"), It.IsAny(), diff --git a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs index 13a6ff36..e54f9ace 100644 --- a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs +++ b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using Moq; using MongoDB.Bson; using MongoDB.Driver; @@ -480,10 +481,13 @@ public async Task Users_GetLoggedInUser_ReturnsNotFound_WhenNoUserIsAuthenticate .ReturnsAsync((User?)null); var controller = new UsersController(_userServiceMock.Object, _userRepoMock.Object, + _workflowInstanceRepoMock.Object, _rightsService, _eduIdUserServiceMock.Object, - _workflowInstanceRepoMock.Object, - _externalUserEmailUpdateService); + null!, + null!, + _externalUserEmailUpdateService, + Mock.Of>()); var result = await controller.GetLoggedInUser(_ct); @@ -624,10 +628,13 @@ private UsersController BuildControllerWithRoles( return new UsersController(_userServiceMock.Object, _userRepoMock.Object, + _workflowInstanceRepoMock.Object, _rightsService, _eduIdUserServiceMock.Object, - _workflowInstanceRepoMock.Object, - _externalUserEmailUpdateService); + null!, + null!, + _externalUserEmailUpdateService, + Mock.Of>()); } private static bool IsConfiguredInternalEmail(string email) diff --git a/UvA.Workflow.Tests/Impersonation/UsersControllerImpersonationTests.cs b/UvA.Workflow.Tests/Impersonation/UsersControllerImpersonationTests.cs index 349f0c74..277be07d 100644 --- a/UvA.Workflow.Tests/Impersonation/UsersControllerImpersonationTests.cs +++ b/UvA.Workflow.Tests/Impersonation/UsersControllerImpersonationTests.cs @@ -48,8 +48,10 @@ private static UsersController BuildController(bool isSuperAdmin, bool targetExi null!, null!, null!, + null!, new HttpContextCurrentUserAccessor(httpAccessor), new UserImpersonationTokenService(config, httpAccessor), + null!, Mock.Of>()) { ControllerContext = new ControllerContext { HttpContext = ctx }