diff --git a/UvA.Workflow.Api/Actions/ActionsController.cs b/UvA.Workflow.Api/Actions/ActionsController.cs index 580b76f..0ea8d32 100644 --- a/UvA.Workflow.Api/Actions/ActionsController.cs +++ b/UvA.Workflow.Api/Actions/ActionsController.cs @@ -1,12 +1,12 @@ using UvA.Workflow.Api.Actions.Dtos; using UvA.Workflow.Api.Infrastructure; -using UvA.Workflow.Api.WorkflowInstances; using UvA.Workflow.Api.WorkflowInstances.Dtos; namespace UvA.Workflow.Api.Actions; public class ActionsController( IWorkflowInstanceRepository workflowInstanceRepository, + IUserService userService, RightsService rightsService, TriggerService triggerService, WorkflowInstanceDtoFactory workflowInstanceDtoFactory, @@ -17,6 +17,10 @@ InstanceService instanceService public async Task> ExecuteAction([FromBody] ExecuteActionInputDto input, CancellationToken ct) { + var currentUser = await userService.GetCurrentUser(ct); + if (currentUser == null) + return Unauthorized(); + var instance = await workflowInstanceRepository.GetById(input.InstanceId, ct); if (instance == null) return WorkflowInstanceNotFound; @@ -38,7 +42,7 @@ public async Task> ExecuteAction([FromBody if (action == null) return Forbidden(); - await triggerService.RunTriggers(instance, action.Triggers, ct, input.Mail); + await triggerService.RunTriggers(instance, action.Triggers, currentUser, ct, input.Mail); await instanceService.UpdateCurrentStep(instance, ct); break; } diff --git a/UvA.Workflow.Api/Authentication/SurfConextAuthenticationHandler.cs b/UvA.Workflow.Api/Authentication/SurfConextAuthenticationHandler.cs index 154546a..ebdfc19 100644 --- a/UvA.Workflow.Api/Authentication/SurfConextAuthenticationHandler.cs +++ b/UvA.Workflow.Api/Authentication/SurfConextAuthenticationHandler.cs @@ -8,8 +8,8 @@ namespace UvA.Workflow.Api.Authentication; public class SurfConextAuthenticationHandler : AuthenticationHandler { - private const string SURFCONEXT_ERROR = "SurfConextError"; - public static string Scheme => "SURFconext"; + private const string SurfconextError = "SurfConextError"; + public static string SchemeName => "SURFconext"; /// /// implements the behavior of the SurfConext scheme to authenticate users. @@ -56,7 +56,7 @@ protected override async Task HandleAuthenticateAsync() var cacheKey = $"bt_{bearerToken}"; if (cache.TryGetValue(cacheKey, out ClaimsPrincipal? cachedPrincipal)) - return AuthenticateResult.Success(new AuthenticationTicket(cachedPrincipal!, Scheme)); + return AuthenticateResult.Success(new AuthenticationTicket(cachedPrincipal!, SchemeName)); var resp = await ValidateSurfBearerToken(bearerToken); if (resp == null) @@ -81,7 +81,7 @@ protected override async Task HandleAuthenticateAsync() await userService.AddOrUpdateUser(principal.Identity!.Name!, resp.FullName, resp.Email); - return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme)); + return AuthenticateResult.Success(new AuthenticationTicket(principal, SchemeName)); } protected override Task HandleChallengeAsync(AuthenticationProperties properties) @@ -91,7 +91,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties { Status = StatusCodes.Status401Unauthorized, Title = "Unauthorized", - Detail = Context.Items[SURFCONEXT_ERROR] as string ?? "Unauthorized", + Detail = Context.Items[SurfconextError] as string ?? "Unauthorized", Instance = Context.Request.Path.Value }, new JsonSerializerOptions(JsonSerializerDefaults.Web)); @@ -112,7 +112,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties "Token validation failed: SurfConext returned status {Code}: {Response}, ClientId:{ClientId}, Secret:{ClientSecret}", response.StatusCode, content, OptionsMonitor.CurrentValue.ClientId, OptionsMonitor.CurrentValue.ClientSecret?[..4]); - Context.Items[SURFCONEXT_ERROR] = + Context.Items[SurfconextError] = $"Token validation failed: SurfConext returned status {response.StatusCode}, check the logs for details"; return null; } @@ -124,7 +124,7 @@ protected override Task HandleChallengeAsync(AuthenticationProperties properties catch (Exception ex) { Logger.LogError(ex, "Token validation failed: unable to deserialize response: {Response}", content); - Context.Items[SURFCONEXT_ERROR] = + Context.Items[SurfconextError] = $"Token validation failed: unable to deserialize response from SurfConext, check the logs for details"; return null; } @@ -148,7 +148,7 @@ private static ClaimsPrincipal CreateClaimsPrincipal(IntrospectionResponse r) if (r.Uids is { Length: > 0 } && !string.IsNullOrWhiteSpace(r.Uids[0])) { - claims.Add(new Claim(ClaimTypes.NameIdentifier, r.Uids[0])); + claims.Add(new Claim(ClaimTypes.NameIdentifier, UvaClaimTypes.UvanetId)); claims.Add(new Claim(UvaClaimTypes.UvanetId, r.Uids[0])); } @@ -177,7 +177,7 @@ private static ClaimsPrincipal CreateClaimsPrincipal(IntrospectionResponse r) claims.Add(new Claim("updated_at", r.UpdatedAt.Value.ToString(System.Globalization.CultureInfo.InvariantCulture))); - var identity = new ClaimsIdentity(claims, Scheme, UvaClaimTypes.UvanetId, ClaimTypes.Role); + var identity = new ClaimsIdentity(claims, SchemeName, UvaClaimTypes.UvanetId, ClaimTypes.Role); return new ClaimsPrincipal(identity); } } \ No newline at end of file diff --git a/UvA.Workflow.Api/Authentication/SurfConextExtensions.cs b/UvA.Workflow.Api/Authentication/SurfConextExtensions.cs index 3edb776..8ef387f 100644 --- a/UvA.Workflow.Api/Authentication/SurfConextExtensions.cs +++ b/UvA.Workflow.Api/Authentication/SurfConextExtensions.cs @@ -31,7 +31,8 @@ public static IServiceCollection AddSurfConextAuthentication(this IServiceCollec services.AddAuthentication(authOptions => { - authOptions.AddScheme(SurfConextAuthenticationHandler.Scheme, null); + authOptions.AddScheme(SurfConextAuthenticationHandler.SchemeName, + null); }); services.AddSwaggerGen(c => diff --git a/UvA.Workflow.Api/Events/EventsController.cs b/UvA.Workflow.Api/Events/EventsController.cs index 484b8b4..fbc1969 100644 --- a/UvA.Workflow.Api/Events/EventsController.cs +++ b/UvA.Workflow.Api/Events/EventsController.cs @@ -1,19 +1,27 @@ using UvA.Workflow.Api.Infrastructure; +using UvA.Workflow.Events; namespace UvA.Workflow.Api.Events; [Route("/WorkflowInstances/{instanceId}/Events")] -public class EventsController(IWorkflowInstanceRepository workflowRepository, InstanceService instanceService) +public class EventsController( + IWorkflowInstanceRepository workflowRepository, + IUserService userService, + IInstanceEventService eventService) : ApiControllerBase { [HttpDelete] [Route("{eventName}")] public async Task DeleteEvent(string instanceId, string eventName, CancellationToken ct) { + var user = await userService.GetCurrentUser(ct); + if (user == null) + return Unauthorized(); + var instance = await workflowRepository.GetById(instanceId, ct); if (instance == null) return WorkflowInstanceNotFound; - await instanceService.DeleteEvent(instance, eventName, ct); + await eventService.DeleteEvent(instance, eventName, user, ct); return Ok(); } } \ No newline at end of file diff --git a/UvA.Workflow.Api/Infrastructure/ServiceCollectionExtentions.cs b/UvA.Workflow.Api/Infrastructure/ServiceCollectionExtentions.cs index 5732ee6..feb1afc 100644 --- a/UvA.Workflow.Api/Infrastructure/ServiceCollectionExtentions.cs +++ b/UvA.Workflow.Api/Infrastructure/ServiceCollectionExtentions.cs @@ -1,5 +1,6 @@ using UvA.Workflow.Api.Screens; using UvA.Workflow.Api.Submissions.Dtos; +using UvA.Workflow.Events; using UvA.Workflow.Infrastructure.Database; using UvA.Workflow.Persistence; using UvA.Workflow.Submissions; @@ -26,6 +27,7 @@ public static IServiceCollection AddWorkflow(this IServiceCollection services, I // Register repositories - organized by domain feature services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -39,6 +41,8 @@ public static IServiceCollection AddWorkflow(this IServiceCollection services, I services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/UvA.Workflow.Api/Screens/ScreenDataService.cs b/UvA.Workflow.Api/Screens/ScreenDataService.cs index b5fab37..693ec77 100644 --- a/UvA.Workflow.Api/Screens/ScreenDataService.cs +++ b/UvA.Workflow.Api/Screens/ScreenDataService.cs @@ -1,4 +1,5 @@ using UvA.Workflow.Api.Screens.Dtos; +using UvA.Workflow.Events; using UvA.Workflow.Tools; namespace UvA.Workflow.Api.Screens; diff --git a/UvA.Workflow.Api/Submissions/Dtos/SubmissionDto.cs b/UvA.Workflow.Api/Submissions/Dtos/SubmissionDto.cs index fbf25db..689be34 100644 --- a/UvA.Workflow.Api/Submissions/Dtos/SubmissionDto.cs +++ b/UvA.Workflow.Api/Submissions/Dtos/SubmissionDto.cs @@ -1,4 +1,5 @@ using UvA.Workflow.Api.Infrastructure; +using UvA.Workflow.Events; using UvA.Workflow.Submissions; namespace UvA.Workflow.Api.Submissions.Dtos; diff --git a/UvA.Workflow.Api/Submissions/SubmissionsController.cs b/UvA.Workflow.Api/Submissions/SubmissionsController.cs index 4d49751..fb944ac 100644 --- a/UvA.Workflow.Api/Submissions/SubmissionsController.cs +++ b/UvA.Workflow.Api/Submissions/SubmissionsController.cs @@ -6,6 +6,7 @@ namespace UvA.Workflow.Api.Submissions; public class SubmissionsController( + IUserService userService, ModelService modelService, SubmissionService submissionService, SubmissionDtoFactory submissionDtoFactory, @@ -26,15 +27,19 @@ public async Task> GetSubmission(string instanceId, public async Task> SubmitSubmission(string instanceId, string submissionId, CancellationToken ct) { + var currentUser = await userService.GetCurrentUser(ct); + if (currentUser == null) + return Unauthorized(); var context = await submissionService.GetSubmissionContext(instanceId, submissionId, ct); var (instance, sub, form, _) = context; - var result = await submissionService.SubmitSubmission(context, ct); + var result = await submissionService.SubmitSubmission(context, currentUser, ct); if (!result.Success) { var submissionDto = submissionDtoFactory.Create(instance, form, sub, modelService.GetQuestionStatus(instance, form, true)); - return Ok(new SubmitSubmissionResult(submissionDto, null, result.Errors, false)); + + return UnprocessableEntity(new SubmitSubmissionResult(submissionDto, null, result.Errors, false)); } var finalSubmissionDto = submissionDtoFactory.Create(instance, form, instance.Events[submissionId], diff --git a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs index dcec11c..039e6ed 100644 --- a/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs +++ b/UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs @@ -1,6 +1,7 @@ using UvA.Workflow.Api.Actions.Dtos; using UvA.Workflow.Api.EntityTypes.Dtos; using UvA.Workflow.Api.Submissions.Dtos; +using UvA.Workflow.Events; namespace UvA.Workflow.Api.WorkflowInstances.Dtos; diff --git a/UvA.Workflow.Tests/Builders/EventBuilder.cs b/UvA.Workflow.Tests/Builders/EventBuilder.cs index afd1909..8a51650 100644 --- a/UvA.Workflow.Tests/Builders/EventBuilder.cs +++ b/UvA.Workflow.Tests/Builders/EventBuilder.cs @@ -1,3 +1,4 @@ +using UvA.Workflow.Events; using UvA.Workflow.WorkflowInstances; namespace UvA.Workflow.Tests; diff --git a/UvA.Workflow.Tests/Builders/WorkflowInstanceBuilder.cs b/UvA.Workflow.Tests/Builders/WorkflowInstanceBuilder.cs index 9c80d23..043287e 100644 --- a/UvA.Workflow.Tests/Builders/WorkflowInstanceBuilder.cs +++ b/UvA.Workflow.Tests/Builders/WorkflowInstanceBuilder.cs @@ -1,4 +1,5 @@ using MongoDB.Bson; +using UvA.Workflow.Events; using UvA.Workflow.WorkflowInstances; namespace UvA.Workflow.Tests; diff --git a/UvA.Workflow.Tests/WorkflowTests.cs b/UvA.Workflow.Tests/WorkflowTests.cs index 5b2c1dc..42078d7 100644 --- a/UvA.Workflow.Tests/WorkflowTests.cs +++ b/UvA.Workflow.Tests/WorkflowTests.cs @@ -4,6 +4,7 @@ using MongoDB.Driver; using Moq; using UvA.Workflow.Entities.Domain; +using UvA.Workflow.Events; using UvA.Workflow.Persistence; using UvA.Workflow.Services; using UvA.Workflow.Submissions; @@ -14,13 +15,15 @@ namespace UvA.Workflow.Tests; public class WorkflowTests { - Mock repoMock; + Mock instanceRepoMock; + Mock eventRepoMock; Mock userServiceMock; Mock mailServiceMock; Mock artifactServiceMock; ModelService modelService; RightsService rightsService; InstanceService instanceService; + InstanceEventService eventService; TriggerService triggerService; SubmissionService submissionService; ModelParser parser; @@ -31,7 +34,8 @@ public class WorkflowTests public WorkflowTests() { // Mocks - repoMock = new Mock(); + instanceRepoMock = new Mock(); + eventRepoMock = new Mock(); userServiceMock = new Mock(); mailServiceMock = new Mock(); artifactServiceMock = new Mock(); @@ -41,9 +45,12 @@ public WorkflowTests() parser = new ModelParser(modelProvider); modelService = new ModelService(parser); rightsService = new RightsService(modelService, userServiceMock.Object); - instanceService = new InstanceService(repoMock.Object, modelService, userServiceMock.Object, rightsService); - triggerService = new TriggerService(instanceService, modelService, mailServiceMock.Object); - submissionService = new SubmissionService(repoMock.Object, modelService, triggerService, instanceService); + instanceService = + new InstanceService(instanceRepoMock.Object, modelService, userServiceMock.Object, rightsService); + eventService = new InstanceEventService(eventRepoMock.Object, rightsService, instanceService); + triggerService = new TriggerService(instanceService, eventService, modelService, mailServiceMock.Object); + submissionService = + new SubmissionService(instanceRepoMock.Object, modelService, triggerService, instanceService); answerConversionService = new AnswerConversionService(userServiceMock.Object); answerService = new AnswerService(submissionService, modelService, instanceService, rightsService, artifactServiceMock.Object, answerConversionService); @@ -87,7 +94,7 @@ public async Task SubmitForm_FillAnswer_Success() ) .Build(); - repoMock.Setup(r => r.GetById(instance.Id, It.IsAny())).ReturnsAsync(instance); + instanceRepoMock.Setup(r => r.GetById(instance.Id, It.IsAny())).ReturnsAsync(instance); JsonElement value = JsonDocument.Parse("\"title\"").RootElement; // Act @@ -96,7 +103,7 @@ public async Task SubmitForm_FillAnswer_Success() // Assert Assert.Contains(instance.Properties, p => p.Key == "Title" && p.Value.ToString() == "title"); - repoMock.Verify( + instanceRepoMock.Verify( r => r.UpdateFields(instance.Id, It.IsAny>(), It.IsAny()), Times.Once); } @@ -117,7 +124,7 @@ public async Task SubmitForm_UploadArtifact_Success() await writer.FlushAsync(ct); const string fileName = "test.pdf"; - repoMock.Setup(r => r.GetById(instance.Id, It.IsAny())).ReturnsAsync(instance); + instanceRepoMock.Setup(r => r.GetById(instance.Id, It.IsAny())).ReturnsAsync(instance); artifactServiceMock.Setup(a => a.SaveArtifact(fileName, It.IsAny())) .ReturnsAsync(new ArtifactInfo(ObjectId.GenerateNewId(), fileName)); @@ -130,7 +137,7 @@ public async Task SubmitForm_UploadArtifact_Success() var report = BsonSerializer.Deserialize(instance.Properties["Report"].ToBsonDocument()); Assert.Equal(fileName, report.Name); artifactServiceMock.Verify(a => a.SaveArtifact(fileName, It.IsAny()), Times.Once); - repoMock.Verify( + instanceRepoMock.Verify( r => r.UpdateFields(instance.Id, It.IsAny>(), It.IsAny()), Times.Once); } diff --git a/UvA.Workflow/Events/IInstanceEventRepository.cs b/UvA.Workflow/Events/IInstanceEventRepository.cs new file mode 100644 index 0000000..5525304 --- /dev/null +++ b/UvA.Workflow/Events/IInstanceEventRepository.cs @@ -0,0 +1,27 @@ +namespace UvA.Workflow.Events; + +public interface IInstanceEventRepository +{ + /// + /// Adds a new event to a workflow instance or updates an existing event if it already exists. + /// Logs the operation specifying whether it was an addition or update. + /// + /// The workflow instance in which the event should be added or updated. + /// The new event to add or the existing event to update. + /// The user initiating the add or update operation. + /// The cancellation token used to observe the operation's cancellation. + /// An asynchronous operation representing the add or update process. + Task AddOrUpdateEvent(WorkflowInstance instance, InstanceEvent newEvent, User user, + CancellationToken ct); + + /// + /// Deletes a specified event from a workflow instance and logs the deletion. + /// + /// The workflow instance from which the event is to be deleted. + /// The event to remove from the instance. + /// The user executing the deletion action. + /// The cancellation token used to observe the operation's cancellation. + /// An asynchronous operation representing the deletion process. + Task DeleteEvent(WorkflowInstance instance, InstanceEvent eventToDelete, User user, + CancellationToken ct); +} \ No newline at end of file diff --git a/UvA.Workflow/Events/InstanceEvent.cs b/UvA.Workflow/Events/InstanceEvent.cs new file mode 100644 index 0000000..789125e --- /dev/null +++ b/UvA.Workflow/Events/InstanceEvent.cs @@ -0,0 +1,7 @@ +namespace UvA.Workflow.Events; + +public class InstanceEvent +{ + public string Id { get; set; } = null!; + public DateTime? Date { get; set; } +} \ No newline at end of file diff --git a/UvA.Workflow/Events/InstanceEventLogEntry.cs b/UvA.Workflow/Events/InstanceEventLogEntry.cs new file mode 100644 index 0000000..6f8652a --- /dev/null +++ b/UvA.Workflow/Events/InstanceEventLogEntry.cs @@ -0,0 +1,38 @@ +using MongoDB.Bson.Serialization.Attributes; + +namespace UvA.Workflow.Events; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EventLogOperation +{ + Create, + Update, + Delete +} + +public class InstanceEventLogEntry +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = null!; + + [BsonElement("Timestamp")] public DateTime Timestamp { get; set; } = DateTime.UtcNow; + + [BsonElement("InstanceId")] + [BsonRepresentation(BsonType.ObjectId)] + public string WorkflowInstanceId { get; set; } = null!; + + [BsonElement("EventId")] public string EventId { get; set; } = null!; + + [BsonElement("ExecutedBy")] + [BsonRepresentation(BsonType.ObjectId)] + public string ExecutedBy { get; set; } = null!; + + [BsonElement("EventDate")] + [BsonDateTimeOptions(Kind = DateTimeKind.Utc)] + public DateTime? EventDate { get; set; } + + [BsonRepresentation(BsonType.String)] + [BsonElement("Operation")] + public EventLogOperation Operation { get; set; } +} \ No newline at end of file diff --git a/UvA.Workflow/Events/InstanceEventRepository.cs b/UvA.Workflow/Events/InstanceEventRepository.cs new file mode 100644 index 0000000..766324f --- /dev/null +++ b/UvA.Workflow/Events/InstanceEventRepository.cs @@ -0,0 +1,96 @@ +namespace UvA.Workflow.Events; + +public class InstanceEventRepository(IMongoDatabase database) : IInstanceEventRepository +{ + private readonly IMongoCollection _eventLogCollection = + database.GetCollection("eventlog"); + + private readonly IMongoCollection _instanceCollection = + database.GetCollection("instances"); + + /// + /// Adds a new event to a workflow instance or updates an existing event if it already exists. + /// Logs the operation specifying whether it was an addition or update. + /// + /// The workflow instance in which the event should be added or updated. + /// The new event to add or the existing event to update. + /// The user initiating the add or update operation. + /// The cancellation token used to observe the operation's cancellation. + /// An asynchronous operation representing the add or update process. + public async Task AddOrUpdateEvent(WorkflowInstance instance, InstanceEvent newEvent, User user, + CancellationToken ct) + { + // Add or update existing event in the instance + var filter = Builders.Filter.Eq(i => i.Id, instance.Id); + var update = Builders.Update + .Set(i => i.Events[newEvent.Id], newEvent); + + // Use FindOneAndUpdate to apply the change AND return the state Before the change + var options = new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.Before, + // Optional: Optimize by only retrieving the Events field + Projection = Builders.Projection.Include(i => i.Events) + }; + + var originalDoc = + await _instanceCollection.FindOneAndUpdateAsync(filter, update, options, cancellationToken: ct); + + // Determine operation type by checking if the key existed previously + var wasUpdated = originalDoc != null && + originalDoc.Events.ContainsKey(newEvent.Id); + + // Also add the event to the event log collection + await AddEventLogEntry(instance, newEvent, user, + wasUpdated ? EventLogOperation.Update : EventLogOperation.Create, ct); + } + + /// + /// Deletes a specified event from a workflow instance and logs the deletion. + /// + /// The workflow instance from which the event is to be deleted. + /// The event to remove from the instance. + /// The user executing the deletion action. + /// The cancellation token used to observe the operation's cancellation. + /// An asynchronous operation representing the deletion process. + public async Task DeleteEvent(WorkflowInstance instance, InstanceEvent eventToDelete, User user, + CancellationToken ct) + { + var filter = Builders.Filter.Eq(i => i.Id, instance.Id); + + var delete = Builders.Update + .Unset(i => i.Events[eventToDelete.Id]); + + var options = new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.Before, + Projection = Builders.Projection.Include(i => i.Events) + }; + + // Perform atomic delete and retrieve the instance state BEFORE the delete + var originalDoc = + await _instanceCollection.FindOneAndUpdateAsync(filter, delete, options, cancellationToken: ct); + + // Verify the instance and event existed before logging + if (originalDoc != null && originalDoc.Events.ContainsKey(eventToDelete.Id)) + { + // Also add the deletion of the event to the event log collection + await AddEventLogEntry(instance, eventToDelete, user, EventLogOperation.Delete, ct); + } + } + + private async Task AddEventLogEntry(WorkflowInstance instance, InstanceEvent instanceEvent, User user, + EventLogOperation operation, CancellationToken ct) + { + var logEntry = new InstanceEventLogEntry + { + Timestamp = DateTime.UtcNow, + WorkflowInstanceId = instance.Id, + EventId = instanceEvent.Id, + EventDate = instanceEvent.Date, + Operation = operation, + ExecutedBy = user.Id + }; + await _eventLogCollection.InsertOneAsync(logEntry, cancellationToken: ct); + } +} \ No newline at end of file diff --git a/UvA.Workflow/Events/InstanceEventService.cs b/UvA.Workflow/Events/InstanceEventService.cs new file mode 100644 index 0000000..da370bd --- /dev/null +++ b/UvA.Workflow/Events/InstanceEventService.cs @@ -0,0 +1,56 @@ +using UvA.Workflow.Infrastructure; + +namespace UvA.Workflow.Events; + +public interface IInstanceEventService +{ + Task UpdateEvent(WorkflowInstance instance, string eventId, User user, CancellationToken ct); + + /// + /// Deletes a specific event from the provided workflow instance based on the given event ID. + /// + /// The workflow instance from which the event will be deleted. + /// The unique identifier of the event to be deleted. + /// The user performing the delete action. + /// A token to monitor for cancellation requests. + /// + /// Thrown when the specified event ID is not found within the workflow instance. + /// + Task DeleteEvent(WorkflowInstance instance, string eventId, User user, CancellationToken ct); +} + +public class InstanceEventService( + IInstanceEventRepository eventRepository, + RightsService rightsService, + InstanceService instanceService) : IInstanceEventService +{ + public async Task UpdateEvent(WorkflowInstance instance, string eventId, User user, CancellationToken ct) + { + var newEvent = instance.RecordEvent(eventId); + await eventRepository.AddOrUpdateEvent(instance, newEvent, user, ct); + } + + /// + /// Deletes a specific event from the provided workflow instance based on the given event ID. + /// + /// The workflow instance from which the event will be deleted. + /// The unique identifier of the event to be deleted. + /// The user performing the delete action. + /// A token to monitor for cancellation requests. + /// + /// Thrown when the specified event ID is not found within the workflow instance. + /// + public async Task DeleteEvent(WorkflowInstance instance, string eventId, User user, CancellationToken ct) + { + await rightsService.EnsureAuthorizedForAction(instance, RoleAction.ViewAdminTools); + + if (instance.Events.TryGetValue(eventId, out InstanceEvent? instanceEvent)) + { + await eventRepository.DeleteEvent(instance, instanceEvent, user, ct); + instance.Events.Remove(eventId); + await instanceService.UpdateCurrentStep(instance, ct); + } + else + throw new EntityNotFoundException(nameof(InstanceEvent), eventId); + } +} \ No newline at end of file diff --git a/UvA.Workflow/Submissions/AnswerService.cs b/UvA.Workflow/Submissions/AnswerService.cs index 1d32d14..930f8b1 100644 --- a/UvA.Workflow/Submissions/AnswerService.cs +++ b/UvA.Workflow/Submissions/AnswerService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Serilog; +using UvA.Workflow.Events; using UvA.Workflow.Infrastructure; using UvA.Workflow.Persistence; diff --git a/UvA.Workflow/Submissions/SubmissionService.cs b/UvA.Workflow/Submissions/SubmissionService.cs index 8883636..c0300e1 100644 --- a/UvA.Workflow/Submissions/SubmissionService.cs +++ b/UvA.Workflow/Submissions/SubmissionService.cs @@ -1,3 +1,4 @@ +using UvA.Workflow.Events; using UvA.Workflow.Infrastructure; namespace UvA.Workflow.Submissions; @@ -35,7 +36,7 @@ public async Task GetSubmissionContext(string instanceId, str return new SubmissionContext(instance, submission, form, submissionId); } - public async Task SubmitSubmission(SubmissionContext context, CancellationToken ct) + public async Task SubmitSubmission(SubmissionContext context, User user, CancellationToken ct) { var (instance, submission, form, submissionId) = context; @@ -68,7 +69,7 @@ public async Task SubmitSubmission(SubmissionContext context, return new SubmissionResult(false, validationErrors); } - await triggerService.RunTriggers(instance, [new Trigger { Event = submissionId }, ..form.OnSubmit], ct); + await triggerService.RunTriggers(instance, [new Trigger { Event = submissionId }, ..form.OnSubmit], user, ct); // Save the updated instance await instanceService.UpdateCurrentStep(instance, ct); diff --git a/UvA.Workflow/UvA.Workflow.csproj b/UvA.Workflow/UvA.Workflow.csproj index 8804c67..a2d5b61 100644 --- a/UvA.Workflow/UvA.Workflow.csproj +++ b/UvA.Workflow/UvA.Workflow.csproj @@ -32,6 +32,9 @@ ..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\9.0.0\Microsoft.AspNetCore.StaticFiles.dll + + ..\..\..\..\..\usr\local\share\dotnet\shared\Microsoft.AspNetCore.App\9.0.0\Microsoft.Extensions.Caching.Abstractions.dll + ..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\9.0.0\Microsoft.Extensions.Configuration.Abstractions.dll diff --git a/UvA.Workflow/WorkflowInstances/IWorkflowInstanceRepository.cs b/UvA.Workflow/WorkflowInstances/IWorkflowInstanceRepository.cs index 5e9607f..7dca4ef 100644 --- a/UvA.Workflow/WorkflowInstances/IWorkflowInstanceRepository.cs +++ b/UvA.Workflow/WorkflowInstances/IWorkflowInstanceRepository.cs @@ -40,5 +40,4 @@ Task UpdateField(string instanceId, Expression updateDefinition, CancellationToken ct); - Task DeleteField(string instanceId, Expression> field, CancellationToken ct); } \ No newline at end of file diff --git a/UvA.Workflow/WorkflowInstances/InstanceService.cs b/UvA.Workflow/WorkflowInstances/InstanceService.cs index 1a355f4..a84aaba 100644 --- a/UvA.Workflow/WorkflowInstances/InstanceService.cs +++ b/UvA.Workflow/WorkflowInstances/InstanceService.cs @@ -1,7 +1,8 @@ +using UvA.Workflow.Events; using UvA.Workflow.Infrastructure; using Domain_Action = UvA.Workflow.Entities.Domain.Action; -namespace UvA.Workflow.Services; +namespace UvA.Workflow.WorkflowInstances; public class InstanceService( IWorkflowInstanceRepository workflowInstanceRepository, @@ -132,35 +133,6 @@ public async Task CheckLimit(WorkflowInstance instance, Domain_Action acti return users.Count(u => u.Id == user!.Id) < action.Limit.Value; } - public async Task UpdateEvent(WorkflowInstance instance, string eventId, CancellationToken ct) - { - instance.RecordEvent(eventId); - await workflowInstanceRepository.Update(instance, ct); - } - - /// - /// Deletes a specific event from the given workflow instance based on the provided event ID. - /// - /// The workflow instance from which the event will be deleted. - /// The unique identifier of the event to be deleted. - /// A token to monitor for cancellation requests. - /// - /// Thrown when the specified event ID is not found within the workflow instance. - /// - public async Task DeleteEvent(WorkflowInstance instance, string eventId, CancellationToken ct) - { - await rightsService.EnsureAuthorizedForAction(instance, RoleAction.ViewAdminTools); - - // TODO: needs to be updated to remove the most recent event with the specified eventId once multiple events of same id per workflowinstance is implemented - if (instance.Events.Remove(eventId)) - { - await workflowInstanceRepository.DeleteField(instance.Id, i => i.Events[eventId], ct); - await UpdateCurrentStep(instance, ct); - } - else - throw new EntityNotFoundException(nameof(InstanceEvent), eventId); - } - public Task SaveValue(WorkflowInstance instance, string? part1, string part2, CancellationToken ct) => workflowInstanceRepository.UpdateFields(instance.Id, Builders.Update.Set(part1 == null diff --git a/UvA.Workflow/WorkflowInstances/TriggerService.cs b/UvA.Workflow/WorkflowInstances/TriggerService.cs index c464d08..d901f46 100644 --- a/UvA.Workflow/WorkflowInstances/TriggerService.cs +++ b/UvA.Workflow/WorkflowInstances/TriggerService.cs @@ -1,15 +1,21 @@ -namespace UvA.Workflow.Services; +using UvA.Workflow.Events; -public class TriggerService(InstanceService instanceService, ModelService modelService, IMailService mailService) +namespace UvA.Workflow.WorkflowInstances; + +public class TriggerService( + InstanceService instanceService, + IInstanceEventService eventService, + ModelService modelService, + IMailService mailService) { - public async Task RunTriggers(WorkflowInstance instance, Trigger[] triggers, CancellationToken ct, + public async Task RunTriggers(WorkflowInstance instance, Trigger[] triggers, User user, CancellationToken ct, MailMessage? mail = null) { var context = modelService.CreateContext(instance); foreach (var trigger in triggers.Where(t => t.Condition.IsMet(context))) { - if (trigger.Event != null) await Event(instance, trigger.Event, ct); - if (trigger.UndoEvent != null) await UndoEvent(instance, trigger.UndoEvent, ct); + if (trigger.Event != null) await AddEvent(instance, trigger.Event, user, ct); + if (trigger.UndoEvent != null) await UndoEvent(instance, trigger.UndoEvent, user, ct); if (trigger.SendMail != null) await SendMail(instance, trigger.SendMail, ct, mail); if (trigger.SetProperty != null) await SetProperty(instance, trigger.SetProperty, ct); } @@ -26,20 +32,20 @@ private async Task SendMail(WorkflowInstance instance, SendMessage sendMail, Can await mailService.Send(mail); } - private async Task UndoEvent(WorkflowInstance instance, string eventName, CancellationToken ct) + private async Task UndoEvent(WorkflowInstance instance, string eventName, User user, CancellationToken ct) { if (!instance.Events.TryGetValue(eventName, out var ev)) return; ev.Date = null; - await instanceService.UpdateEvent(instance, ev.Id, ct); + await eventService.UpdateEvent(instance, ev.Id, user, ct); } - private async Task Event(WorkflowInstance instance, string eventName, CancellationToken ct) + private async Task AddEvent(WorkflowInstance instance, string eventName, User user, CancellationToken ct) { var ev = instance.Events.GetValueOrDefault(eventName); ev ??= instance.Events[eventName] = new InstanceEvent { Id = eventName }; ev.Date = DateTime.Now; - await instanceService.UpdateEvent(instance, ev.Id, ct); + await eventService.UpdateEvent(instance, ev.Id, user, ct); } private async Task SetProperty(WorkflowInstance instance, SetProperty setProperty, CancellationToken ct) diff --git a/UvA.Workflow/WorkflowInstances/WorkflowInstance.cs b/UvA.Workflow/WorkflowInstances/WorkflowInstance.cs index 24beb1b..9e0bf1e 100644 --- a/UvA.Workflow/WorkflowInstances/WorkflowInstance.cs +++ b/UvA.Workflow/WorkflowInstances/WorkflowInstance.cs @@ -1,5 +1,5 @@ using MongoDB.Bson.Serialization.Attributes; -using UvA.Workflow.Tools; +using UvA.Workflow.Events; namespace UvA.Workflow.WorkflowInstances; @@ -70,13 +70,15 @@ public void TransitionToStep(string newStep) /// /// Records an event in the workflow /// - public void RecordEvent(string eventId, DateTime? date = null) + public InstanceEvent RecordEvent(string eventId, DateTime? date = null) { - Events[eventId] = new InstanceEvent + var newEvent = new InstanceEvent { Id = eventId, Date = date ?? DateTime.UtcNow }; + Events[eventId] = newEvent; + return newEvent; } /// @@ -108,12 +110,6 @@ public bool ValidateRequiredProperties(params string[] requiredProperties) } } -public class InstanceEvent -{ - public string Id { get; set; } = null!; - public DateTime? Date { get; set; } -} - public record StateLogEntry(string State, DateTime Date, string? UserId); public record CurrencyAmount(string Currency, double Amount); diff --git a/UvA.Workflow/WorkflowInstances/WorkflowInstanceRepository.cs b/UvA.Workflow/WorkflowInstances/WorkflowInstanceRepository.cs index f16d10c..27a4bd1 100644 --- a/UvA.Workflow/WorkflowInstances/WorkflowInstanceRepository.cs +++ b/UvA.Workflow/WorkflowInstances/WorkflowInstanceRepository.cs @@ -8,13 +8,13 @@ namespace UvA.Workflow.WorkflowInstances; /// public class WorkflowInstanceRepository(IMongoDatabase database) : IWorkflowInstanceRepository { - private readonly IMongoCollection _collection = + private readonly IMongoCollection instanceCollection = database.GetCollection("instances"); public async Task Create(WorkflowInstance instance, CancellationToken ct) { var document = instance; - await _collection.InsertOneAsync(document, cancellationToken: ct); + await instanceCollection.InsertOneAsync(document, cancellationToken: ct); instance.Id = document.Id; // Update with generated ID } @@ -24,7 +24,7 @@ public async Task Create(WorkflowInstance instance, CancellationToken ct) return null; var filter = Builders.Filter.Eq("_id", objectId); - var instance = await _collection.Find(filter).FirstOrDefaultAsync(ct); + var instance = await instanceCollection.Find(filter).FirstOrDefaultAsync(ct); return instance; } @@ -34,7 +34,7 @@ public async Task Update(WorkflowInstance instance, CancellationToken ct) throw new ArgumentException("Invalid instance ID", nameof(instance.Id)); var filter = Builders.Filter.Eq("_id", objectId); - await _collection.ReplaceOneAsync(filter, instance, cancellationToken: ct); + await instanceCollection.ReplaceOneAsync(filter, instance, cancellationToken: ct); } public async Task Delete(string id, CancellationToken ct) @@ -43,7 +43,7 @@ public async Task Delete(string id, CancellationToken ct) return; var filter = Builders.Filter.Eq("_id", objectId); - await _collection.DeleteOneAsync(filter, ct); + await instanceCollection.DeleteOneAsync(filter, ct); } public async Task> GetByIds(IEnumerable ids, CancellationToken ct) @@ -55,28 +55,28 @@ public async Task> GetByIds(IEnumerable id .ToList(); var filter = Builders.Filter.In("_id", objectIds); - var documents = await _collection.Find(filter).ToListAsync(ct); + var documents = await instanceCollection.Find(filter).ToListAsync(ct); return documents; } public async Task> GetByEntityType(string entityType, CancellationToken ct) { var filter = Builders.Filter.Eq(x => x.EntityType, entityType); - var documents = await _collection.Find(filter).ToListAsync(ct); + var documents = await instanceCollection.Find(filter).ToListAsync(ct); return documents; } public async Task> GetByParentId(string parentId, CancellationToken ct) { var filter = Builders.Filter.Eq(x => x.ParentId, parentId); - var documents = await _collection.Find(filter).ToListAsync(ct); + var documents = await instanceCollection.Find(filter).ToListAsync(ct); return documents; } public async Task> GetAll(Expression> expression, CancellationToken ct) { - return await _collection.Find(expression).ToListAsync(ct); + return await instanceCollection.Find(expression).ToListAsync(ct); } public async Task Get(string instanceId, Expression> expression, @@ -84,7 +84,7 @@ public async Task> GetAll(Expression.Projection.Expression(expression); var filter = Builders.Filter.Eq(p => p.Id, instanceId); - return await _collection.Find(filter).Project(projection).FirstOrDefaultAsync(ct); + return await instanceCollection.Find(filter).Project(projection).FirstOrDefaultAsync(ct); } public async Task Get(Expression> predicate, @@ -92,7 +92,7 @@ public async Task> GetAll(Expression.Projection.Expression(project); var filter = Builders.Filter.Where(predicate); - return await _collection.Find(filter).Project(projection).FirstOrDefaultAsync(ct); + return await instanceCollection.Find(filter).Project(projection).FirstOrDefaultAsync(ct); } public async Task>> GetAllByType(string entityType, @@ -104,7 +104,7 @@ public async Task>> GetAllByType(string entit new("$project", projection.ToBsonDocument()) ]; - return await _collection.Aggregate>(pipeline).ToListAsync(ct); + return await instanceCollection.Aggregate>(pipeline).ToListAsync(ct); } public async Task>> GetAllByParentId(string parentId, @@ -116,7 +116,7 @@ public async Task>> GetAllByParentId(string p new("$project", projection.ToBsonDocument()) ]; - return await _collection.Aggregate>(pipeline).ToListAsync(ct); + return await instanceCollection.Aggregate>(pipeline).ToListAsync(ct); } public async Task>> GetAllById(string[] ids, @@ -129,7 +129,7 @@ public async Task>> GetAllById(string[] ids, new("$project", projection.ToBsonDocument()) ]; - return await _collection.Aggregate>(pipeline).ToListAsync(ct); + return await instanceCollection.Aggregate>(pipeline).ToListAsync(ct); } public async Task UpdateField(string instanceId, Expression> field, @@ -141,7 +141,7 @@ public async Task UpdateField(string instanceId, Expression.Filter.Eq("_id", objectId); var update = Builders.Update.Set(field, value); - await _collection.UpdateOneAsync(filter, update, cancellationToken: ct); + await instanceCollection.UpdateOneAsync(filter, update, cancellationToken: ct); } public async Task DeleteField(string instanceId, Expression> field, @@ -153,7 +153,7 @@ public async Task DeleteField(string instanceId, Expression.Filter.Eq("_id", objectId); var update = Builders.Update.Unset(field); - await _collection.UpdateOneAsync(filter, update, cancellationToken: ct); + await instanceCollection.UpdateOneAsync(filter, update, cancellationToken: ct); } public async Task UpdateFields(string instanceId, UpdateDefinition updateDefinition, @@ -163,6 +163,6 @@ public async Task UpdateFields(string instanceId, UpdateDefinition.Filter.Eq("_id", objectId); - await _collection.UpdateOneAsync(filter, updateDefinition, cancellationToken: ct); + await instanceCollection.UpdateOneAsync(filter, updateDefinition, cancellationToken: ct); } } \ No newline at end of file diff --git a/UvA.Workflow/WorkflowInstances/WorkflowInstanceService.cs b/UvA.Workflow/WorkflowInstances/WorkflowInstanceService.cs index 84fef5d..93c259c 100644 --- a/UvA.Workflow/WorkflowInstances/WorkflowInstanceService.cs +++ b/UvA.Workflow/WorkflowInstances/WorkflowInstanceService.cs @@ -1,3 +1,5 @@ +using UvA.Workflow.Events; + namespace UvA.Workflow.WorkflowInstances; public class WorkflowInstanceService(