diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/CreateApprovalRequest.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/CreateApprovalRequest.cs deleted file mode 100644 index 9dce30b..0000000 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/CreateApprovalRequest.cs +++ /dev/null @@ -1,139 +0,0 @@ -using FastEndpoints; -using Microsoft.EntityFrameworkCore; -using System.Security.Cryptography; -using Socialize.Api.Data; -using Socialize.Api.Infrastructure.Security; -using Socialize.Api.Modules.Approvals.Data; -using Socialize.Api.Modules.Approvals.Services; -using Socialize.Api.Modules.Notifications.Contracts; -using Socialize.Api.Modules.Workspaces.Data; - -namespace Socialize.Api.Modules.Approvals.Handlers; - -public record CreateApprovalRequestRequest( - Guid WorkspaceId, - Guid ContentItemId, - string Stage, - string ReviewerName, - string ReviewerEmail, - DateTimeOffset? DueAt); - -public class CreateApprovalRequestRequestValidator - : Validator -{ - public CreateApprovalRequestRequestValidator() - { - RuleFor(x => x.WorkspaceId).NotEmpty(); - RuleFor(x => x.ContentItemId).NotEmpty(); - RuleFor(x => x.Stage).NotEmpty().MaximumLength(64); - RuleFor(x => x.ReviewerName).NotEmpty().MaximumLength(256); - RuleFor(x => x.ReviewerEmail).NotEmpty().MaximumLength(256).EmailAddress(); - } -} - -public class CreateApprovalRequestHandler( - AppDbContext dbContext, - AccessScopeService accessScopeService, - INotificationEventWriter notificationEventWriter) - : Endpoint -{ - public override void Configure() - { - Post("/api/approvals"); - Options(o => o.WithTags("Approvals")); - } - - public override async Task HandleAsync(CreateApprovalRequestRequest request, CancellationToken ct) - { - var contentItem = await dbContext - .ContentItems - .SingleOrDefaultAsync( - candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId, - ct); - - if (contentItem is null) - { - AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace."); - await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); - return; - } - - if (!accessScopeService.CanManageWorkspace(User, contentItem.WorkspaceId)) - { - await SendForbiddenAsync(ct); - return; - } - - Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == contentItem.WorkspaceId, ct); - if (workspace is null) - { - await SendNotFoundAsync(ct); - return; - } - - if (!ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(workspace.ApprovalMode)) - { - AddError(request => request.WorkspaceId, workspace.ApprovalMode == ApprovalModes.None - ? "Approval workflow is disabled for this workspace." - : "Move content to In approval to start the configured multi-level approval workflow."); - await SendErrorsAsync(StatusCodes.Status400BadRequest, ct); - return; - } - - var approval = new ApprovalRequest() - { - Id = Guid.NewGuid(), - WorkspaceId = request.WorkspaceId, - ContentItemId = request.ContentItemId, - Stage = request.Stage.Trim(), - ReviewerName = request.ReviewerName.Trim(), - ReviewerEmail = request.ReviewerEmail.Trim(), - RequestedByUserId = User.GetUserId(), - DueAt = request.DueAt, - State = "Pending", - AccessToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(), - SentAt = DateTimeOffset.UtcNow, - }; - - dbContext.ApprovalRequests.Add(approval); - - contentItem.Status = "In approval"; - - await dbContext.SaveChangesAsync(ct); - - await notificationEventWriter.WriteAsync( - new NotificationEventWriteModel( - approval.WorkspaceId, - approval.ContentItemId, - "approval.requested", - "ApprovalRequest", - approval.Id, - $"Approval requested from {approval.ReviewerName} for {contentItem.Title}.", - null, - approval.ReviewerEmail, - $$"""{"stage":"{{approval.Stage}}","accessToken":"{{approval.AccessToken}}"}"""), - ct); - - ApprovalRequestDto dto = new( - approval.Id, - approval.WorkspaceId, - approval.ContentItemId, - approval.WorkflowInstanceId, - approval.WorkflowStepSortOrder, - approval.WorkflowStepTargetType, - approval.WorkflowStepTargetValue, - approval.WorkflowStepRequiredApproverCount, - approval.Stage, - approval.ReviewerName, - approval.ReviewerEmail, - approval.RequestedByUserId, - approval.DueAt, - approval.State, - approval.AccessToken, - approval.SentAt, - approval.CompletedAt, - []); - - await SendAsync(dto, StatusCodes.Status201Created, ct); - } -} diff --git a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs index 1db69f7..85486de 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Handlers/SubmitApprovalDecision.cs @@ -12,7 +12,6 @@ namespace Socialize.Api.Modules.Approvals.Handlers; public record SubmitApprovalDecisionRequest( string Decision, - string? Comment, string? ReviewerName, string? ReviewerEmail); @@ -25,7 +24,6 @@ public class SubmitApprovalDecisionRequestValidator .NotEmpty() .Equal("Approved") .WithMessage("Only approved decisions are supported."); - RuleFor(x => x.Comment).MaximumLength(2048); RuleFor(x => x.ReviewerName).MaximumLength(256); RuleFor(x => x.ReviewerEmail).MaximumLength(256).EmailAddress().When(x => !string.IsNullOrWhiteSpace(x.ReviewerEmail)); } @@ -90,7 +88,7 @@ public class SubmitApprovalDecisionHandler( Id = Guid.NewGuid(), ApprovalRequestId = approval.Id, Decision = normalizedDecision, - Comment = string.IsNullOrWhiteSpace(request.Comment) ? null : request.Comment.Trim(), + Comment = null, DecidedByUserId = User?.Identity?.IsAuthenticated == true ? User.GetUserId() : null, DecidedByName = decidedByName, DecidedByEmail = decidedByEmail, diff --git a/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRules.cs b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRules.cs index 0a831cd..7a816cb 100644 --- a/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRules.cs +++ b/backend/src/Socialize.Api/Modules/Approvals/Services/ApprovalWorkflowRules.cs @@ -12,11 +12,6 @@ public static class ApprovalModes public static class ApprovalWorkflowRules { - public static bool CanCreateSingleStepApprovalRequest(string approvalMode) - { - return approvalMode is ApprovalModes.Optional or ApprovalModes.Required; - } - public static bool BlocksManualApprovedOrScheduledStatus(string approvalMode) { return approvalMode is ApprovalModes.Required or ApprovalModes.MultiLevel; diff --git a/backend/tests/Socialize.Tests/Approvals/ApprovalWorkflowRulesTests.cs b/backend/tests/Socialize.Tests/Approvals/ApprovalWorkflowRulesTests.cs index d185217..0718a0b 100644 --- a/backend/tests/Socialize.Tests/Approvals/ApprovalWorkflowRulesTests.cs +++ b/backend/tests/Socialize.Tests/Approvals/ApprovalWorkflowRulesTests.cs @@ -7,18 +7,6 @@ namespace Socialize.Tests.Approvals; public class ApprovalWorkflowRulesTests { - [Theory] - [InlineData(ApprovalModes.Optional, true)] - [InlineData(ApprovalModes.Required, true)] - [InlineData(ApprovalModes.None, false)] - [InlineData(ApprovalModes.MultiLevel, false)] - public void CanCreateSingleStepApprovalRequest_matches_basic_modes(string approvalMode, bool expected) - { - bool actual = ApprovalWorkflowRules.CanCreateSingleStepApprovalRequest(approvalMode); - - Assert.Equal(expected, actual); - } - [Theory] [InlineData(ApprovalModes.Required, true)] [InlineData(ApprovalModes.MultiLevel, true)] diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 295a8f6..81d0874 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -861,7 +861,7 @@ export interface paths { }; get: operations["SocializeApiModulesApprovalsHandlersGetApprovalsHandler"]; put?: never; - post: operations["SocializeApiModulesApprovalsHandlersCreateApprovalRequestHandler"]; + post?: never; delete?: never; options?: never; head?: never; @@ -1540,22 +1540,9 @@ export interface components { /** Format: date-time */ createdAt?: string; }; - SocializeApiModulesApprovalsHandlersCreateApprovalRequestRequest: { - /** Format: guid */ - workspaceId: string; - /** Format: guid */ - contentItemId: string; - stage: string; - reviewerName: string; - /** Format: email */ - reviewerEmail: string; - /** Format: date-time */ - dueAt?: string | null; - }; SocializeApiModulesApprovalsHandlersGetApprovalsRequest: Record; SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionRequest: { decision: string; - comment?: string | null; reviewerName?: string | null; /** Format: email */ reviewerEmail?: string | null; @@ -3652,46 +3639,6 @@ export interface operations { }; }; }; - SocializeApiModulesApprovalsHandlersCreateApprovalRequestHandler: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["SocializeApiModulesApprovalsHandlersCreateApprovalRequestRequest"]; - }; - }; - responses: { - /** @description Success */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["SocializeApiModulesApprovalsHandlersApprovalRequestDto"]; - }; - }; - /** @description Bad Request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["FastEndpointsErrorResponse"]; - }; - }; - /** @description Unauthorized */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; SocializeApiModulesApprovalsHandlersSubmitApprovalDecisionHandler: { parameters: { query?: never; diff --git a/frontend/src/features/content/components/ContentApprovalPanel.vue b/frontend/src/features/content/components/ContentApprovalPanel.vue new file mode 100644 index 0000000..896e7b7 --- /dev/null +++ b/frontend/src/features/content/components/ContentApprovalPanel.vue @@ -0,0 +1,462 @@ + + + + + diff --git a/frontend/src/features/content/stores/contentItemDetailStore.js b/frontend/src/features/content/stores/contentItemDetailStore.js index b311d05..7b985cc 100644 --- a/frontend/src/features/content/stores/contentItemDetailStore.js +++ b/frontend/src/features/content/stores/contentItemDetailStore.js @@ -20,7 +20,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = asset: false, assetRevision: false, comment: false, - approval: false, decision: false, status: false, }); @@ -159,26 +158,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = } } - async function createApproval(contentItemId, payload) { - actions.approval = true; - - try { - const response = await client.post('/api/approvals', { - ...payload, - contentItemId, - workspaceId: workspaceStore.activeWorkspaceId, - }); - if (response.data) { - approvals.value = [response.data, ...approvals.value]; - await fetchContentItem(contentItemId); - await fetchNotifications(contentItemId); - } - return response.data; - } finally { - actions.approval = false; - } - } - async function submitDecision(contentItemId, approvalId, payload) { actions.decision = true; @@ -248,7 +227,6 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () = addAssetRevision, addComment, resolveComment, - createApproval, submitDecision, updateStatus, }; diff --git a/frontend/src/features/content/views/ContentItemDetailView.vue b/frontend/src/features/content/views/ContentItemDetailView.vue index 284e29f..76bd80c 100644 --- a/frontend/src/features/content/views/ContentItemDetailView.vue +++ b/frontend/src/features/content/views/ContentItemDetailView.vue @@ -1,10 +1,11 @@