Refine content approval workflow rail
This commit is contained in:
@@ -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<CreateApprovalRequestRequest>
|
||||
{
|
||||
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<CreateApprovalRequestRequest, ApprovalRequestDto>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
55
frontend/src/api/schema.d.ts
vendored
55
frontend/src/api/schema.d.ts
vendored
@@ -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<string, never>;
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
approvals: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isCreateMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
approvalMode: {
|
||||
type: String,
|
||||
default: 'Required',
|
||||
},
|
||||
contentStatus: {
|
||||
type: String,
|
||||
default: 'Draft',
|
||||
},
|
||||
isSubmittingDecision: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit-decision']);
|
||||
|
||||
const isMultiLevelApproval = computed(() => props.approvalMode === 'Multi-level');
|
||||
const isApprovalDisabled = computed(() => props.approvalMode === 'None');
|
||||
const sortedApprovals = computed(() =>
|
||||
[...props.approvals].sort((left, right) => {
|
||||
const leftOrder = left.workflowStepSortOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
const rightOrder = right.workflowStepSortOrder ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (left.workflowInstanceId || right.workflowInstanceId) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
|
||||
return new Date(left.sentAt ?? 0).getTime() - new Date(right.sentAt ?? 0).getTime();
|
||||
})
|
||||
);
|
||||
const currentPendingApprovalId = computed(() =>
|
||||
sortedApprovals.value.find(approval => approval.state === 'Pending')?.id ?? null
|
||||
);
|
||||
const lifecycleRanks = {
|
||||
Draft: 0,
|
||||
'In production': 1,
|
||||
'In approval': 2,
|
||||
Approved: 3,
|
||||
Scheduled: 4,
|
||||
Published: 5,
|
||||
};
|
||||
const currentLifecycleRank = computed(() => lifecycleRanks[props.contentStatus] ?? 0);
|
||||
const hasApprovalSteps = computed(() => sortedApprovals.value.length > 0);
|
||||
const railSteps = computed(() => [
|
||||
productionStep(),
|
||||
...approvalSteps(),
|
||||
publicationStep(),
|
||||
]);
|
||||
|
||||
function submitDecision(approvalId) {
|
||||
emit('submit-decision', approvalId, {
|
||||
decision: 'Approved',
|
||||
reviewerName: '',
|
||||
reviewerEmail: '',
|
||||
});
|
||||
}
|
||||
|
||||
function formatApprovalStepMeta(approval) {
|
||||
if (!approval.workflowInstanceId) {
|
||||
return `${approval.stage} · ${approval.state}`;
|
||||
}
|
||||
|
||||
const stepNumber = Number(approval.workflowStepSortOrder ?? 0) + 1;
|
||||
const requiredCount = approval.workflowStepRequiredApproverCount ?? 1;
|
||||
return `Step ${stepNumber} · ${approval.state} · ${requiredCount} required`;
|
||||
}
|
||||
|
||||
function formatTarget(approval) {
|
||||
if (!approval.workflowStepTargetType) {
|
||||
return approval.reviewerEmail || 'Direct reviewer';
|
||||
}
|
||||
|
||||
return `${approval.workflowStepTargetType}: ${approval.workflowStepTargetValue}`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return value ? new Date(value).toLocaleDateString() : 'No due date';
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
return value ? new Date(value).toLocaleString() : '';
|
||||
}
|
||||
|
||||
function stepStatus(approval) {
|
||||
if (approval.state === 'Approved') {
|
||||
return 'approved';
|
||||
}
|
||||
|
||||
if (approval.id === currentPendingApprovalId.value) {
|
||||
return 'current';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function canRecordDecision(approval) {
|
||||
if (approval.state !== 'Pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !approval.workflowInstanceId || approval.id === currentPendingApprovalId.value;
|
||||
}
|
||||
|
||||
function productionStep() {
|
||||
const status = currentLifecycleRank.value > lifecycleRanks['In production']
|
||||
? 'approved'
|
||||
: 'current';
|
||||
|
||||
return {
|
||||
id: 'production',
|
||||
kind: 'production',
|
||||
title: 'Production',
|
||||
state: props.contentStatus === 'Draft' ? 'Draft' : 'In production',
|
||||
status,
|
||||
meta: props.contentStatus === 'Draft'
|
||||
? 'Content is being drafted.'
|
||||
: 'Content is being prepared before approval.',
|
||||
action: '',
|
||||
};
|
||||
}
|
||||
|
||||
function approvalSteps() {
|
||||
if (hasApprovalSteps.value) {
|
||||
return sortedApprovals.value.map((approval, index) => ({
|
||||
id: approval.id,
|
||||
kind: 'approval',
|
||||
approval,
|
||||
title: approval.stage || approval.reviewerName || `Approval ${index + 1}`,
|
||||
state: approval.state,
|
||||
status: stepStatus(approval),
|
||||
meta: formatApprovalStepMeta(approval),
|
||||
action: canRecordDecision(approval) ? 'Click the circle to approve.' : '',
|
||||
}));
|
||||
}
|
||||
|
||||
return [{
|
||||
id: 'approval',
|
||||
kind: 'approval-empty',
|
||||
title: 'Approval',
|
||||
state: approvalStateLabel(),
|
||||
status: approvalSyntheticStatus(),
|
||||
meta: approvalEmptyMeta(),
|
||||
action: '',
|
||||
}];
|
||||
}
|
||||
|
||||
function publicationStep() {
|
||||
const status = props.contentStatus === 'Published'
|
||||
? 'published'
|
||||
: props.contentStatus === 'Scheduled'
|
||||
? 'scheduled'
|
||||
: 'pending';
|
||||
|
||||
return {
|
||||
id: 'publication',
|
||||
kind: 'publication',
|
||||
title: props.contentStatus === 'Published' ? 'Published' : 'Publication',
|
||||
state: props.contentStatus === 'Published'
|
||||
? 'Published'
|
||||
: props.contentStatus === 'Scheduled'
|
||||
? 'Scheduled'
|
||||
: 'Pending',
|
||||
status,
|
||||
meta: props.contentStatus === 'Published'
|
||||
? 'Content has been published.'
|
||||
: props.contentStatus === 'Scheduled'
|
||||
? 'Content is scheduled for publishing.'
|
||||
: 'Content is not scheduled or published yet.',
|
||||
action: '',
|
||||
};
|
||||
}
|
||||
|
||||
function approvalSyntheticStatus() {
|
||||
if (isApprovalDisabled.value || currentLifecycleRank.value > lifecycleRanks['In approval']) {
|
||||
return 'approved';
|
||||
}
|
||||
|
||||
if (props.contentStatus === 'In approval') {
|
||||
return 'current';
|
||||
}
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function approvalStateLabel() {
|
||||
if (isApprovalDisabled.value) {
|
||||
return 'Skipped';
|
||||
}
|
||||
|
||||
if (currentLifecycleRank.value > lifecycleRanks['In approval']) {
|
||||
return 'Approved';
|
||||
}
|
||||
|
||||
if (props.contentStatus === 'In approval') {
|
||||
return 'In approval';
|
||||
}
|
||||
|
||||
return 'Pending';
|
||||
}
|
||||
|
||||
function approvalEmptyMeta() {
|
||||
if (isApprovalDisabled.value) {
|
||||
return 'Approval is disabled for this workspace.';
|
||||
}
|
||||
|
||||
if (isMultiLevelApproval.value) {
|
||||
return 'Move this content to In approval to start the configured workflow steps.';
|
||||
}
|
||||
|
||||
return 'No approval activity yet.';
|
||||
}
|
||||
|
||||
function stepLabel(step, index) {
|
||||
const action = step.action ? ` ${step.action}` : '';
|
||||
return `${step.title}. ${step.state}. ${step.meta}${action || ''}`.trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="approval-panel"
|
||||
aria-label="Approval workflow"
|
||||
>
|
||||
<div
|
||||
v-if="isCreateMode"
|
||||
class="approval-empty"
|
||||
>
|
||||
<span class="step-circle is-muted">1</span>
|
||||
<div class="step-popover">
|
||||
<strong>Approval</strong>
|
||||
<span>Save the content first to see workflow steps.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol
|
||||
v-else
|
||||
class="approval-stepper"
|
||||
>
|
||||
<li
|
||||
v-for="(step, index) in railSteps"
|
||||
:key="step.id"
|
||||
class="approval-step"
|
||||
:class="`is-${step.status}`"
|
||||
>
|
||||
<button
|
||||
class="step-circle"
|
||||
type="button"
|
||||
:disabled="step.kind !== 'approval' || !canRecordDecision(step.approval) || isSubmittingDecision"
|
||||
:aria-label="stepLabel(step, index)"
|
||||
@click="submitDecision(step.approval.id)"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</button>
|
||||
|
||||
<div class="step-popover">
|
||||
<div class="popover-heading">
|
||||
<strong>{{ step.title }}</strong>
|
||||
<span>{{ step.state }}</span>
|
||||
</div>
|
||||
|
||||
<div class="popover-meta">
|
||||
<span>Workflow</span>
|
||||
<strong>{{ step.meta }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="popover-meta">
|
||||
<template v-if="step.kind === 'approval'">
|
||||
<span>Approver</span>
|
||||
<strong>{{ step.approval.reviewerName || formatTarget(step.approval) }}</strong>
|
||||
<small>{{ formatTarget(step.approval) }}</small>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>Status</span>
|
||||
<strong>{{ step.state }}</strong>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="step.kind === 'approval'"
|
||||
class="popover-meta"
|
||||
>
|
||||
<span>Due</span>
|
||||
<strong>{{ formatDate(step.approval.dueAt) }}</strong>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="step.kind === 'approval' && step.approval.decisions?.length"
|
||||
class="decision-list"
|
||||
>
|
||||
<article
|
||||
v-for="decision in step.approval.decisions"
|
||||
:key="decision.id"
|
||||
class="decision-row"
|
||||
>
|
||||
<AppAvatar
|
||||
:name="decision.decidedByName"
|
||||
:email="decision.decidedByEmail"
|
||||
:src="decision.decidedByPortraitUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ decision.decidedByName }}</strong>
|
||||
<span>{{ decision.decision }} · {{ formatDateTime(decision.createdAt) }}</span>
|
||||
<small v-if="decision.comment">{{ decision.comment }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="popover-meta"
|
||||
>
|
||||
<span>Action</span>
|
||||
<strong>{{ step.action || 'No action available' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.approval-panel {
|
||||
@apply relative flex w-11 justify-center self-start;
|
||||
}
|
||||
|
||||
.approval-empty strong,
|
||||
.popover-heading strong,
|
||||
.popover-meta strong,
|
||||
.decision-row strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.approval-empty span,
|
||||
.popover-heading span,
|
||||
.popover-meta span,
|
||||
.popover-meta small,
|
||||
.decision-row span,
|
||||
.decision-row small {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.approval-stepper,
|
||||
.decision-list {
|
||||
@apply flex flex-col gap-14;
|
||||
}
|
||||
|
||||
.approval-step {
|
||||
@apply relative flex justify-center;
|
||||
}
|
||||
|
||||
.approval-step:not(:last-child)::after {
|
||||
@apply absolute bottom-[-3.5rem] top-10 border-l-2 border-dashed;
|
||||
content: '';
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-color: rgba(23, 32, 51, 0.18);
|
||||
}
|
||||
|
||||
.approval-empty {
|
||||
@apply relative flex justify-center;
|
||||
}
|
||||
|
||||
.step-circle {
|
||||
@apply relative z-10 flex h-9 w-9 items-center justify-center rounded-full border text-xs font-black transition;
|
||||
background: #fffdf8;
|
||||
border-color: rgba(23, 32, 51, 0.16);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
button.step-circle:not(:disabled) {
|
||||
@apply cursor-pointer shadow-sm;
|
||||
}
|
||||
|
||||
button.step-circle:not(:disabled):hover,
|
||||
button.step-circle:not(:disabled):focus-visible {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
button.step-circle:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.step-circle.is-muted {
|
||||
background: rgba(23, 32, 51, 0.04);
|
||||
}
|
||||
|
||||
.approval-step.is-approved .step-circle {
|
||||
background: #0f766e;
|
||||
border-color: #0f766e;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.approval-step.is-scheduled .step-circle {
|
||||
background: #b45309;
|
||||
border-color: #b45309;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.approval-step.is-published .step-circle {
|
||||
background: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.approval-step.is-current .step-circle {
|
||||
background: #172033;
|
||||
border-color: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.step-popover {
|
||||
@apply pointer-events-none absolute left-[calc(100%+0.75rem)] top-0 z-20 flex w-[18rem] translate-y-2 flex-col gap-3 rounded-[1rem] border p-4 opacity-0 shadow-xl transition;
|
||||
background: #ffffff;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
}
|
||||
|
||||
.approval-step:hover .step-popover,
|
||||
.approval-step:focus-within .step-popover,
|
||||
.approval-empty:hover .step-popover {
|
||||
@apply pointer-events-auto translate-y-0 opacity-100;
|
||||
}
|
||||
|
||||
.popover-heading {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.popover-meta {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.decision-row {
|
||||
@apply flex items-start gap-3 rounded-[0.875rem] border p-3;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.decision-row div {
|
||||
@apply flex min-w-0 flex-col gap-1;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.step-popover {
|
||||
width: min(18rem, calc(100vw - 5rem));
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue';
|
||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
|
||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||
import ContentApprovalPanel from '@/features/content/components/ContentApprovalPanel.vue';
|
||||
import { useContentItemDetailStore } from '@/features/content/stores/contentItemDetailStore.js';
|
||||
import { useContentItemsStore } from '@/features/content/stores/contentItemsStore.js';
|
||||
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
|
||||
@@ -33,26 +34,10 @@
|
||||
placements: [],
|
||||
});
|
||||
|
||||
const approvalForm = reactive({
|
||||
stage: 'Internal',
|
||||
reviewerName: '',
|
||||
reviewerEmail: '',
|
||||
dueAt: '',
|
||||
});
|
||||
|
||||
const commentForm = reactive({
|
||||
body: '',
|
||||
});
|
||||
|
||||
const decisionForms = reactive({});
|
||||
const manualStatuses = [
|
||||
'Draft',
|
||||
'In production',
|
||||
'In approval',
|
||||
'Approved',
|
||||
'Scheduled',
|
||||
'Published',
|
||||
];
|
||||
const saveError = reactive({
|
||||
message: '',
|
||||
});
|
||||
@@ -88,7 +73,7 @@
|
||||
new Map(campaignsStore.campaigns.map(campaign => [campaign.id, campaign.name]))
|
||||
);
|
||||
const editorKey = computed(() => isCreateMode.value ? `new:${route.query.campaignId ?? 'default'}` : String(route.params.id));
|
||||
const isMultiLevelApproval = computed(() => workspaceStore.activeWorkspace?.approvalMode === 'Multi-level');
|
||||
const approvalMode = computed(() => workspaceStore.activeWorkspace?.approvalMode ?? 'Required');
|
||||
|
||||
function blankPlacement(channel = null) {
|
||||
return {
|
||||
@@ -112,29 +97,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
function getDecisionForm(approvalId) {
|
||||
if (!decisionForms[approvalId]) {
|
||||
decisionForms[approvalId] = {
|
||||
decision: 'Approved',
|
||||
comment: '',
|
||||
reviewerName: '',
|
||||
reviewerEmail: '',
|
||||
};
|
||||
}
|
||||
|
||||
return decisionForms[approvalId];
|
||||
}
|
||||
|
||||
function formatApprovalStepMeta(approval) {
|
||||
if (!approval.workflowInstanceId) {
|
||||
return `${approval.stage} · ${approval.state}`;
|
||||
}
|
||||
|
||||
const stepNumber = Number(approval.workflowStepSortOrder ?? 0) + 1;
|
||||
const requiredCount = approval.workflowStepRequiredApproverCount ?? 1;
|
||||
return `Step ${stepNumber} · ${approval.state} · ${requiredCount} required`;
|
||||
}
|
||||
|
||||
function syncPlacementChannel(placement, value) {
|
||||
const channel = availableChannels.value.find(candidate => candidate.id === value);
|
||||
placement.channelId = value;
|
||||
@@ -353,30 +315,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function submitApproval() {
|
||||
async function submitDecision(approvalId, payload) {
|
||||
if (!contentItemId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await detailStore.createApproval(contentItemId.value, {
|
||||
...approvalForm,
|
||||
dueAt: approvalForm.dueAt ? new Date(approvalForm.dueAt).toISOString() : null,
|
||||
});
|
||||
|
||||
approvalForm.stage = 'Internal';
|
||||
approvalForm.reviewerName = '';
|
||||
approvalForm.reviewerEmail = '';
|
||||
approvalForm.dueAt = '';
|
||||
}
|
||||
|
||||
async function submitDecision(approvalId) {
|
||||
if (!contentItemId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formValue = getDecisionForm(approvalId);
|
||||
await detailStore.submitDecision(contentItemId.value, approvalId, formValue);
|
||||
formValue.comment = '';
|
||||
await detailStore.submitDecision(contentItemId.value, approvalId, payload);
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
@@ -388,22 +332,10 @@
|
||||
commentForm.body = '';
|
||||
}
|
||||
|
||||
async function moveStatus(status) {
|
||||
if (!contentItemId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await detailStore.updateStatus(contentItemId.value, status);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
return value ? new Date(value).toLocaleString() : '';
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return value ? new Date(value).toLocaleDateString() : 'No due date';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
isCreateMode.value,
|
||||
@@ -434,12 +366,6 @@
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isCreateMode.value) {
|
||||
await hydrateEditor();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
detailStore.reset();
|
||||
});
|
||||
@@ -502,165 +428,32 @@
|
||||
{{ saveError.message }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isCreateMode && item"
|
||||
class="quick-actions"
|
||||
>
|
||||
<button
|
||||
v-for="status in manualStatuses"
|
||||
:key="status"
|
||||
class="secondary-button"
|
||||
:disabled="detailStore.actions.status || item.status === status"
|
||||
@click="moveStatus(status)"
|
||||
>
|
||||
{{ status }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid">
|
||||
<aside class="panel side-panel">
|
||||
<div class="panel-heading">
|
||||
<strong>Approval</strong>
|
||||
<span v-if="!isCreateMode">{{ detailStore.approvals.length }} {{ isMultiLevelApproval ? 'steps' : 'requests' }}</span>
|
||||
</div>
|
||||
<section class="work-panel">
|
||||
<ContentApprovalPanel
|
||||
:approvals="detailStore.approvals"
|
||||
:approval-mode="approvalMode"
|
||||
:content-status="item?.status ?? 'Draft'"
|
||||
:is-create-mode="isCreateMode"
|
||||
:is-submitting-decision="detailStore.actions.decision"
|
||||
@submit-decision="submitDecision"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isCreateMode"
|
||||
class="empty-note"
|
||||
>
|
||||
Save the content first to request approvals.
|
||||
</div>
|
||||
<main class="content-panel">
|
||||
<div class="content-section">
|
||||
<div class="section-title-row">
|
||||
<strong>Content</strong>
|
||||
<span>{{ placementSummary || 'No channels selected yet' }}</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="isMultiLevelApproval"
|
||||
class="empty-note"
|
||||
>
|
||||
Move this content to In approval to start the configured workflow steps.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="panel-stack"
|
||||
>
|
||||
<label class="field">
|
||||
<span>Stage</span>
|
||||
<select v-model="approvalForm.stage">
|
||||
<option value="Internal">Internal</option>
|
||||
<option value="Client">Client</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reviewer name</span>
|
||||
<input
|
||||
v-model="approvalForm.reviewerName"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Reviewer email</span>
|
||||
<input
|
||||
v-model="approvalForm.reviewerEmail"
|
||||
type="email"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Due date</span>
|
||||
<input
|
||||
v-model="approvalForm.dueAt"
|
||||
type="date"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="primary-button"
|
||||
:disabled="detailStore.actions.approval"
|
||||
@click="submitApproval"
|
||||
>
|
||||
{{ detailStore.actions.approval ? 'Sending...' : 'Request approval' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-stack">
|
||||
<article
|
||||
v-for="approval in detailStore.approvals"
|
||||
:key="approval.id"
|
||||
class="sub-card"
|
||||
>
|
||||
<div class="sub-card-header">
|
||||
<div>
|
||||
<strong>{{ approval.reviewerName }}</strong>
|
||||
<span>{{ formatApprovalStepMeta(approval) }}</span>
|
||||
</div>
|
||||
<small>{{ formatDate(approval.dueAt) }}</small>
|
||||
</div>
|
||||
|
||||
<div class="timeline-list compact">
|
||||
<article
|
||||
v-for="decision in approval.decisions"
|
||||
:key="decision.id"
|
||||
class="timeline-row"
|
||||
>
|
||||
<div class="identity-row">
|
||||
<AppAvatar
|
||||
:name="decision.decidedByName"
|
||||
:email="decision.decidedByEmail"
|
||||
:src="decision.decidedByPortraitUrl"
|
||||
size="sm"
|
||||
/>
|
||||
<div>
|
||||
<strong>{{ decision.decision }}</strong>
|
||||
<span>{{ decision.comment || decision.decidedByName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<small>{{ formatDateTime(decision.createdAt) }}</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="approval.state === 'Pending'"
|
||||
class="panel-stack subtle"
|
||||
>
|
||||
<label class="field">
|
||||
<span>Decision</span>
|
||||
<select v-model="getDecisionForm(approval.id).decision">
|
||||
<option value="Approved">Approved</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Comment</span>
|
||||
<input
|
||||
v-model="getDecisionForm(approval.id).comment"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="secondary-button"
|
||||
:disabled="detailStore.actions.decision"
|
||||
@click="submitDecision(approval.id)"
|
||||
>
|
||||
Record decision
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
|
||||
<main class="panel content-panel">
|
||||
<div class="content-section">
|
||||
<div class="section-title-row">
|
||||
<strong>Content</strong>
|
||||
<span>{{ placementSummary || 'No channels selected yet' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Title</span>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span>Title</span>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Campaign</span>
|
||||
@@ -921,8 +714,9 @@
|
||||
>
|
||||
Add at least one channel to define where this content will be published.
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
<aside class="panel side-panel">
|
||||
<div class="panel-heading">
|
||||
@@ -1024,7 +818,6 @@
|
||||
|
||||
.editor-header p,
|
||||
.panel-heading span,
|
||||
.sub-card span,
|
||||
.timeline-row span,
|
||||
.timeline-row small,
|
||||
.empty-note,
|
||||
@@ -1035,7 +828,6 @@
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.quick-actions,
|
||||
.status-badges {
|
||||
@apply flex flex-wrap items-center gap-3;
|
||||
}
|
||||
@@ -1062,7 +854,13 @@
|
||||
}
|
||||
|
||||
.editor-grid {
|
||||
@apply grid gap-4 xl:grid-cols-[18rem_minmax(0,1fr)_20rem];
|
||||
@apply grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem];
|
||||
}
|
||||
|
||||
.work-panel {
|
||||
@apply grid min-w-0 grid-cols-[2.75rem_minmax(0,1fr)] items-start gap-4 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -1076,20 +874,18 @@
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
@apply gap-6;
|
||||
@apply flex min-h-0 flex-col gap-6;
|
||||
}
|
||||
|
||||
.panel-heading,
|
||||
.section-title-row,
|
||||
.placement-header,
|
||||
.sub-card-header {
|
||||
.placement-header {
|
||||
@apply flex items-start justify-between gap-3;
|
||||
}
|
||||
|
||||
.panel-heading strong,
|
||||
.section-title-row strong,
|
||||
.placement-header strong,
|
||||
.sub-card strong,
|
||||
.timeline-row strong {
|
||||
color: #172033;
|
||||
}
|
||||
@@ -1172,7 +968,6 @@
|
||||
}
|
||||
|
||||
.placement-card,
|
||||
.sub-card,
|
||||
.media-card {
|
||||
@apply rounded-[1.25rem] border p-4;
|
||||
background: #fffaf2;
|
||||
|
||||
@@ -2799,56 +2799,6 @@
|
||||
}
|
||||
},
|
||||
"/api/approvals": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Approvals",
|
||||
"Api"
|
||||
],
|
||||
"operationId": "SocializeApiModulesApprovalsHandlersCreateApprovalRequestHandler",
|
||||
"requestBody": {
|
||||
"x-name": "CreateApprovalRequestRequest",
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesApprovalsHandlersCreateApprovalRequestRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true,
|
||||
"x-position": 1
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SocializeApiModulesApprovalsHandlersApprovalRequestDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"JWTBearerAuth": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Approvals",
|
||||
@@ -5073,56 +5023,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesApprovalsHandlersCreateApprovalRequestRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"workspaceId",
|
||||
"contentItemId",
|
||||
"stage",
|
||||
"reviewerName",
|
||||
"reviewerEmail"
|
||||
],
|
||||
"properties": {
|
||||
"workspaceId": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"minLength": 1,
|
||||
"nullable": false
|
||||
},
|
||||
"contentItemId": {
|
||||
"type": "string",
|
||||
"format": "guid",
|
||||
"minLength": 1,
|
||||
"nullable": false
|
||||
},
|
||||
"stage": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"minLength": 0,
|
||||
"nullable": false
|
||||
},
|
||||
"reviewerName": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
"minLength": 0,
|
||||
"nullable": false
|
||||
},
|
||||
"reviewerEmail": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"maxLength": 256,
|
||||
"minLength": 0,
|
||||
"pattern": "^[^@]+@[^@]+$",
|
||||
"nullable": false
|
||||
},
|
||||
"dueAt": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"SocializeApiModulesApprovalsHandlersGetApprovalsRequest": {
|
||||
"type": "object",
|
||||
"additionalProperties": false
|
||||
@@ -5139,12 +5039,6 @@
|
||||
"minLength": 1,
|
||||
"nullable": false
|
||||
},
|
||||
"comment": {
|
||||
"type": "string",
|
||||
"maxLength": 2048,
|
||||
"minLength": 0,
|
||||
"nullable": true
|
||||
},
|
||||
"reviewerName": {
|
||||
"type": "string",
|
||||
"maxLength": 256,
|
||||
|
||||
Reference in New Issue
Block a user