diff --git a/Examples/Projects/Context/Entity.yaml b/Examples/Projects/Context/Entity.yaml index fbe9cda1..57127b23 100644 --- a/Examples/Projects/Context/Entity.yaml +++ b/Examples/Projects/Context/Entity.yaml @@ -17,13 +17,32 @@ 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: ConfidentialAdvisor + type: User! + text: + en: Confidential advisor + nl: Vertrouwenspersoon + + - name: StudyAdvisor + type: User! + text: + en: Study advisor + nl: Studieadviseur + + - name: GradingBasis type: GradingBasis! values: diff --git a/Examples/Projects/Project/Entity.yaml b/Examples/Projects/Project/Entity.yaml index 0406dfaa..99de96f2 100644 --- a/Examples/Projects/Project/Entity.yaml +++ b/Examples/Projects/Project/Entity.yaml @@ -22,3 +22,27 @@ steps: - Upload - Assessment - Publication + +relatedUsers: + - property: Supervisor + group: default + - property: Examiner + group: default + - property: Reviewer + group: default + - property: SecondReader + group: default + - property: Course.Coordinator + group: support + - property: Course.ConfidentialAdvisor + group: support + - property: Course.StudyAdvisor + group: support + +relatedUserGrouping: + groups: + - name: default + - name: support + title: + en: Other involved staff + nl: Andere betrokkenen \ No newline at end of file diff --git a/Examples/Projects/Project/Properties.yaml b/Examples/Projects/Project/Properties.yaml index 2534e778..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 @@ -39,6 +40,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/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 new file mode 100644 index 00000000..e45161df --- /dev/null +++ b/UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs @@ -0,0 +1,7 @@ +using System.ComponentModel.DataAnnotations; + +namespace UvA.Workflow.Api.Users.Dtos; + +public record UpdateUserEmailDto( + [Required] [EmailAddress] string Email, + [Required] string InstanceId); \ 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 457bb69b..3bcee172 100644 --- a/UvA.Workflow.Api/Users/Dtos/UserDto.cs +++ b/UvA.Workflow.Api/Users/Dtos/UserDto.cs @@ -11,7 +11,8 @@ public record UserDto( string? PreferredLanguage, Organization? Organization, bool IsExternal, - bool IsSuperAdmin + bool IsSuperAdmin, + bool RequiresInvitation ) { /// @@ -29,7 +30,15 @@ public static UserDto Create(User user, bool isSuperAdmin = false) user.PreferredLanguage, user.Organization, UserProviderKeys.IsExternal(user.ProviderKey), - isSuperAdmin + isSuperAdmin, + user.InvitationState == UserInvitationState.Required ); } + + /// + /// 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, + false, u.InvitationState == UserInvitationState.Required); } \ 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 4576cf5d..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"; @@ -94,6 +97,59 @@ 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) + { + 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 (!ExternalUserEmailUpdateService.CanUpdateExternalUserEmail(user)) + { + return Unprocessable( + "UserEmailUpdateNotAllowed", + "Email address can only be updated for external users that have not started an invitation"); + } + + 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(); + var userChanged = !string.Equals(user.Email, email, StringComparison.Ordinal); + if (!userChanged) + return Ok(UserDto.Create(user)); + + user.Email = email; + if (string.Equals(user.UserName, previousEmail, StringComparison.OrdinalIgnoreCase)) + user.UserName = email; + + await userRepository.Update(user, ct); + await externalUserEmailUpdateService.UpdateAnswerReferences(updatePlan, user, ct); + + return Ok(UserDto.Create(user)); + } + [HttpGet("find")] public async Task>> Find(string query, [FromQuery] bool includeExternalUsers = true, CancellationToken ct = default) diff --git a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs index b30ada5a..3da46689 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; @@ -24,7 +25,8 @@ public record WorkflowInstanceDto( RoleAction[] Permissions, bool CanUseAdminTools, bool CanImpersonate, - string[] ViewerRoles + string[] ViewerRoles, + RelatedUserGroupsDto RelatedUserGroups ); public record FieldDto(string? Key, BilingualString Title, object? Value); @@ -120,4 +122,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 689f71e4..3748bd7e 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.Submissions; using UvA.Workflow.Versioning; @@ -39,13 +40,18 @@ public async Task Create(WorkflowInstance instance, Cancell var canUseAdminTools = realUserActions.Any(a => a.Type == RoleAction.ViewAdminTools); var canImpersonate = realUserActions.Any(a => a.Type == RoleAction.ImpersonateRoles); 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)), @@ -65,7 +71,8 @@ await instanceService.Enrich(workflowDefinition, [context], permissions.Where(a => a.AllForms.Length == 0).Select(a => a.Type).Distinct().ToArray(), canUseAdminTools, canImpersonate, - viewerRoles + viewerRoles, + relatedUsers ); return x; } @@ -223,4 +230,36 @@ private StepVersionDto CreateStepVersionDto(StepVersion stepVersion, WorkflowIns return workflowDef.Forms.FirstOrDefault(form => FormSubmissionState.GetSubmissionEventIds(form).Contains(eventId)); } + + private RelatedUserGroupsDto GetRelatedUsers(WorkflowDefinition workflowDefinition, ObjectContext context) + { + // Resolve each RelatedUser to its user value, keyed by group name + var usersByGroup = workflowDefinition.RelatedUsers + .SelectMany(relatedUser => + { + var value = context.Get(relatedUser.Property); + + var users = value is InstanceUser u ? [u] : value as InstanceUser[] ?? []; + + return users.Select(user => new + { + relatedUser.Group, + Dto = new RelatedUserDto(relatedUser.DisplayTitle, UserDto.CreateFromInstanceUser(user)) + }); + }) + .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.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 fc469e72..e54f9ace 100644 --- a/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs +++ b/UvA.Workflow.Tests/Controllers/UsersControllerTests.cs @@ -1,18 +1,48 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; 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")] @@ -134,6 +164,236 @@ 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 = ExternalUserId, + 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"]); + _userServiceMock.Setup(s => s.GetUser("new@example.org", It.IsAny())) + .ReturnsAsync(user); + + 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); + 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" && + 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", InstanceId), _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", InstanceId), _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 = ExternalUserId, + 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", InstanceId), _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 = ExternalUserId, + 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", 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] + [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 = ExternalUserId, + 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, InstanceId), _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_RequiresAnswerEditRights() + { + _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"]); + + 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] [InlineData("student@uva.nl")] [InlineData("student@sub.uva.nl")] @@ -221,11 +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, null!, null!, - null!); + _externalUserEmailUpdateService, + Mock.Of>()); var result = await controller.GetLoggedInUser(_ct); @@ -338,14 +600,41 @@ 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, + _workflowInstanceRepoMock.Object, _rightsService, _eduIdUserServiceMock.Object, null!, 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 } diff --git a/UvA.Workflow.Tests/WorkflowInheritanceTests.cs b/UvA.Workflow.Tests/WorkflowInheritanceTests.cs new file mode 100644 index 00000000..6fa19030 --- /dev/null +++ b/UvA.Workflow.Tests/WorkflowInheritanceTests.cs @@ -0,0 +1,158 @@ +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()); + } + + [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) + => 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 + """, + _ => "" + }; + } + + 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/Users/InstanceUser.cs b/UvA.Workflow/Users/InstanceUser.cs index 0262fd49..805dae33 100644 --- a/UvA.Workflow/Users/InstanceUser.cs +++ b/UvA.Workflow/Users/InstanceUser.cs @@ -23,6 +23,11 @@ public class InstanceUser [BsonElement("IsExternal")] public bool IsExternal { get; set; } + [BsonElement("InvitationState")] + [BsonIgnoreIfNull] + [BsonRepresentation(BsonType.String)] + public UserInvitationState? InvitationState { get; set; } = null; + public static InstanceUser FromUser(User user) => new() { Id = user.Id, @@ -31,6 +36,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/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, diff --git a/UvA.Workflow/WorkflowModel/Inheritance.cs b/UvA.Workflow/WorkflowModel/Inheritance.cs index 9ae05ce6..e41b3d06 100644 --- a/UvA.Workflow/WorkflowModel/Inheritance.cs +++ b/UvA.Workflow/WorkflowModel/Inheritance.cs @@ -58,6 +58,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) 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) diff --git a/UvA.Workflow/WorkflowModel/RelatedUser.cs b/UvA.Workflow/WorkflowModel/RelatedUser.cs new file mode 100644 index 00000000..44b254af --- /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; } = null!; + + 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 d92494a0..53ab7870 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 ///