Refactor workflows and improve API responses
- Added `BackToPerMethodId` constant and updated validation rules in `CloseWorkflowCommandValidator`. - Refactored `GetWorkflowsQueryHandler` for simpler `hasListingData` logic. - Replaced strongly-typed properties with `JsonElement` in `ContractsSourceBlock` and `FerdinandSourceBlock`. - Updated `WorkflowListingReadModel` to use smaller data types for efficiency. - Refactored `WorkflowRepository` to use JSON-based data retrieval and parsing. - Introduced `MetaOnlyResponse` and `WorkflowsResponse` for consistent API responses. - Updated `DocumentsController` and `WorkflowsController` to use new response types. - Added `ApiKeyAuthMiddleware` for API key validation and integrated it into the middleware pipeline. - Refactored `ExceptionHandlingMiddleware` to use `MetaOnlyResponse` for error handling. - Performed general cleanup and modernization for improved readability and maintainability.
This commit is contained in:
@@ -36,6 +36,7 @@ public sealed class CloseWorkflowCommandValidator : AbstractValidator<CloseWorkf
|
||||
private static readonly string[] AllowedSources = ["Ferdinand", "Contracts"];
|
||||
private const int ApproveMethodId = 2;
|
||||
private const int RejectMethodId = 3;
|
||||
private const int BackToPerMethodId = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes validation rules for <see cref="CloseWorkflowCommand"/>.
|
||||
@@ -57,12 +58,12 @@ public sealed class CloseWorkflowCommandValidator : AbstractValidator<CloseWorkf
|
||||
.WithMessage("Source must be one of the supported source systems.");
|
||||
|
||||
RuleFor(x => x.ClosingMethodId)
|
||||
.Must(methodId => methodId is ApproveMethodId or RejectMethodId)
|
||||
.Must(methodId => methodId is ApproveMethodId or RejectMethodId or BackToPerMethodId)
|
||||
.WithMessage("Unbekannte closing_method_id");
|
||||
|
||||
RuleFor(x => x.Comment)
|
||||
.NotEmpty()
|
||||
.When(x => x.ClosingMethodId == RejectMethodId)
|
||||
.When(x => x.ClosingMethodId is RejectMethodId or BackToPerMethodId)
|
||||
.WithMessage("Kommentar ist fur diese closing_method Pflicht");
|
||||
|
||||
RuleFor(x => x.RejectionReasonId)
|
||||
|
||||
@@ -50,14 +50,12 @@ public sealed class GetWorkflowsQueryHandler(IWorkflowRepository workflowReposit
|
||||
return ApiEnvelopeFactory.Create(data, MetaCode.NoAccess);
|
||||
}
|
||||
|
||||
var hasListingData = data.Sources
|
||||
.SelectMany(source => source switch
|
||||
{
|
||||
FerdinandSourceBlock ferdinand => ferdinand.Listing,
|
||||
ContractsSourceBlock contracts => contracts.Listing,
|
||||
_ => []
|
||||
})
|
||||
.Any();
|
||||
var hasListingData = data.Sources.Any(source => source switch
|
||||
{
|
||||
FerdinandSourceBlock ferdinand => ferdinand.Listing.Count > 0,
|
||||
ContractsSourceBlock contracts => contracts.Listing.Count > 0,
|
||||
_ => false
|
||||
});
|
||||
|
||||
if (!hasListingData)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
namespace DigitalData.TaskFlow.Domain.Workflows;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Represents workflow data for the Contracts source system.
|
||||
/// </summary>
|
||||
@@ -8,30 +11,30 @@ public sealed record ContractsSourceBlock : SourceBlock
|
||||
/// <summary>
|
||||
/// Gets available approval profiles.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ProfileItem> Profiles { get; init; }
|
||||
[JsonPropertyName("profile")]
|
||||
public required IReadOnlyList<JsonElement> Profile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets workflow listing items.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<WorkflowListingItem> Listing { get; init; }
|
||||
[JsonPropertyName("Listing")]
|
||||
public required IReadOnlyList<JsonElement> Listing { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets already executed approvals.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ApprovalItem> Approvals { get; init; }
|
||||
[JsonPropertyName("approvals")]
|
||||
public required IReadOnlyList<JsonElement> Approvals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets document references for workflows.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DocumentReferenceItem> Documents { get; init; }
|
||||
[JsonPropertyName("document")]
|
||||
public required IReadOnlyList<JsonElement> Document { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets available closing methods for workflows.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ClosingMethodItem> ClosingMethods { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets available rejection reasons for rejection-type methods.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RejectionReasonItem> RejectionReasons { get; init; }
|
||||
[JsonPropertyName("closing_method")]
|
||||
public required IReadOnlyList<JsonElement> ClosingMethod { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
namespace DigitalData.TaskFlow.Domain.Workflows;
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// Represents workflow data for the Ferdinand source system.
|
||||
/// </summary>
|
||||
@@ -8,30 +11,36 @@ public sealed record FerdinandSourceBlock : SourceBlock
|
||||
/// <summary>
|
||||
/// Gets available approval profiles.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ProfileItem> Profiles { get; init; }
|
||||
[JsonPropertyName("profile")]
|
||||
public required IReadOnlyList<JsonElement> Profile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets workflow listing items.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<WorkflowListingItem> Listing { get; init; }
|
||||
[JsonPropertyName("Listing")]
|
||||
public required IReadOnlyList<JsonElement> Listing { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets already executed approvals.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ApprovalItem> Approvals { get; init; }
|
||||
[JsonPropertyName("approvals")]
|
||||
public required IReadOnlyList<JsonElement> Approvals { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets document references for workflows.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<DocumentReferenceItem> Documents { get; init; }
|
||||
[JsonPropertyName("document")]
|
||||
public required IReadOnlyList<JsonElement> Document { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets available closing methods for workflows.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<ClosingMethodItem> ClosingMethods { get; init; }
|
||||
[JsonPropertyName("closing_method")]
|
||||
public required IReadOnlyList<JsonElement> ClosingMethod { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets available rejection reasons for rejection-type methods.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<RejectionReasonItem> RejectionReasons { get; init; }
|
||||
[JsonPropertyName("rejection_reason")]
|
||||
public required IReadOnlyList<JsonElement> RejectionReason { get; init; }
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class WorkflowListingReadModel
|
||||
/// Gets or sets the profile identifier.
|
||||
/// </summary>
|
||||
[Column("profileid")]
|
||||
public int ProfileId { get; set; }
|
||||
public byte ProfileId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the voucher type.
|
||||
@@ -53,7 +53,7 @@ public sealed class WorkflowListingReadModel
|
||||
/// Gets or sets the client number.
|
||||
/// </summary>
|
||||
[Column("mandantnr")]
|
||||
public int ClientNumber { get; set; }
|
||||
public short ClientNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client name.
|
||||
@@ -101,7 +101,7 @@ public sealed class WorkflowListingReadModel
|
||||
/// Gets or sets the priority.
|
||||
/// </summary>
|
||||
[Column("priority")]
|
||||
public int Priority { get; set; }
|
||||
public byte Priority { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source group text.
|
||||
|
||||
@@ -3,6 +3,8 @@ using DigitalData.TaskFlow.Domain.Closing;
|
||||
using DigitalData.TaskFlow.Domain.Workflows;
|
||||
using DigitalData.TaskFlow.Infrastructure.Options;
|
||||
using DigitalData.TaskFlow.Infrastructure.Persistence;
|
||||
using DigitalData.TaskFlow.Infrastructure.Persistence.Models;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
@@ -22,78 +24,72 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
|
||||
/// <returns>Grouped workflow response.</returns>
|
||||
public async Task<WorkflowSourcesResponse> GetAsync(int wisagId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string resultType = "table";
|
||||
var objectType = string.IsNullOrWhiteSpace(databaseOptions.Value.WorkflowListingObjectType)
|
||||
? "Ferdinand"
|
||||
: databaseOptions.Value.WorkflowListingObjectType;
|
||||
|
||||
var rows = await dbContext
|
||||
.WorkflowListingReadModels
|
||||
.FromSqlInterpolated($"EXEC dbo.PRCUST_F19_GET_LISTING_DATA @pUSER_ID={wisagId}, @pRESULT_TYPE={resultType}, @pOBJECT_TYPE={objectType}")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (rows.Count == 0)
|
||||
var jsonPayload = await ExecuteListingJsonAsync(wisagId, objectType, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(jsonPayload))
|
||||
{
|
||||
var metaCode = await TryGetWorkflowMetaCodeAsync(wisagId, objectType, cancellationToken);
|
||||
return new WorkflowSourcesResponse { Sources = [] };
|
||||
}
|
||||
|
||||
if (metaCode == 2)
|
||||
using var document = JsonDocument.Parse(jsonPayload);
|
||||
if (!document.RootElement.TryGetProperty("response", out var responseElement))
|
||||
{
|
||||
return new WorkflowSourcesResponse { Sources = [] };
|
||||
}
|
||||
|
||||
if (TryReadMetaCode(responseElement) == 2)
|
||||
{
|
||||
return new WorkflowSourcesResponse { Sources = [] };
|
||||
}
|
||||
|
||||
var sources = new List<SourceBlock>();
|
||||
if (!responseElement.TryGetProperty("sources", out var sourcesElement) || sourcesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return new WorkflowSourcesResponse { Sources = sources };
|
||||
}
|
||||
|
||||
foreach (var sourceElement in sourcesElement.EnumerateArray())
|
||||
{
|
||||
if (!sourceElement.TryGetProperty("source", out var sourceNameElement))
|
||||
{
|
||||
return new WorkflowSourcesResponse
|
||||
continue;
|
||||
}
|
||||
|
||||
var sourceName = sourceNameElement.GetString();
|
||||
if (string.Equals(sourceName, "Ferdinand", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sources.Add(new FerdinandSourceBlock
|
||||
{
|
||||
Sources = []
|
||||
};
|
||||
Source = "Ferdinand",
|
||||
Profile = ReadArray(sourceElement, "profile"),
|
||||
Listing = ReadArray(sourceElement, "Listing"),
|
||||
Approvals = ReadArray(sourceElement, "approvals"),
|
||||
Document = ReadArray(sourceElement, "document"),
|
||||
ClosingMethod = ReadArray(sourceElement, "closing_method"),
|
||||
RejectionReason = ReadArray(sourceElement, "rejection_reason")
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(sourceName, "Contracts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sources.Add(new ContractsSourceBlock
|
||||
{
|
||||
Source = "Contracts",
|
||||
Profile = ReadArray(sourceElement, "profile"),
|
||||
Listing = ReadArray(sourceElement, "Listing"),
|
||||
Approvals = ReadArray(sourceElement, "approvals"),
|
||||
Document = ReadArray(sourceElement, "document"),
|
||||
ClosingMethod = ReadArray(sourceElement, "closing_method")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var listing = rows
|
||||
.Select(row => new WorkflowListingItem
|
||||
{
|
||||
ProcessId = row.ProcessId,
|
||||
ProfileId = row.ProfileId,
|
||||
VoucherType = row.VoucherType,
|
||||
VoucherNumber = row.VoucherNumber,
|
||||
VoucherDate = DateOnly.FromDateTime(row.VoucherDate),
|
||||
CostCenter = row.CostCenter,
|
||||
CostCenterDescription = row.CostCenterDescription,
|
||||
ClientNumber = row.ClientNumber,
|
||||
ClientName = row.ClientName,
|
||||
SupplierNumber = row.SupplierNumber,
|
||||
SupplierName = row.SupplierName,
|
||||
NetAmount = Convert.ToDecimal(row.NetAmount),
|
||||
GrossAmount = Convert.ToDecimal(row.GrossAmount),
|
||||
DiscountAmount = Convert.ToDecimal(row.DiscountAmount),
|
||||
Currency = row.Currency,
|
||||
Priority = row.Priority
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var ferdinandBlock = new FerdinandSourceBlock
|
||||
{
|
||||
Source = "Ferdinand",
|
||||
Profiles = [],
|
||||
Listing = listing,
|
||||
Approvals = [],
|
||||
Documents = [],
|
||||
ClosingMethods = [],
|
||||
RejectionReasons = []
|
||||
};
|
||||
|
||||
var contractsBlock = new ContractsSourceBlock
|
||||
{
|
||||
Source = "Contracts",
|
||||
Profiles = [],
|
||||
Listing = [],
|
||||
Approvals = [],
|
||||
Documents = [],
|
||||
ClosingMethods = [],
|
||||
RejectionReasons = []
|
||||
};
|
||||
|
||||
return new WorkflowSourcesResponse
|
||||
{
|
||||
Sources = [ferdinandBlock, contractsBlock]
|
||||
};
|
||||
return new WorkflowSourcesResponse { Sources = sources };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -124,19 +120,17 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
|
||||
});
|
||||
}
|
||||
|
||||
return EvaluateCloseOutcomeAsync(wisagId, processId, cancellationToken);
|
||||
return EvaluateCloseOutcomeAsync(wisagId, source, processId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<CloseWorkflowResult> EvaluateCloseOutcomeAsync(
|
||||
int wisagId,
|
||||
string source,
|
||||
int processId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var activeForUser = await dbContext.Database
|
||||
.SqlQuery<int>($"SELECT COUNT(1) FROM dbo.TBCUST_F19_LISTING_RESULT WHERE userid = {wisagId} AND processid = {processId}")
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
if (activeForUser > 0)
|
||||
var rows = await ExecuteListingTableAsync(wisagId, source, cancellationToken);
|
||||
if (rows.Any(row => row.ProcessId == processId))
|
||||
{
|
||||
return new CloseWorkflowResult
|
||||
{
|
||||
@@ -146,11 +140,8 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
|
||||
};
|
||||
}
|
||||
|
||||
var activeAnyUser = await dbContext.Database
|
||||
.SqlQuery<int>($"SELECT COUNT(1) FROM dbo.TBCUST_F19_LISTING_RESULT WHERE processid = {processId}")
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
if (activeAnyUser > 0)
|
||||
var metaCode = await TryGetWorkflowMetaCodeAsync(wisagId, source, cancellationToken);
|
||||
if (metaCode == 2)
|
||||
{
|
||||
return new CloseWorkflowResult
|
||||
{
|
||||
@@ -160,20 +151,6 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
|
||||
};
|
||||
}
|
||||
|
||||
var inHistory = await dbContext.Database
|
||||
.SqlQuery<int>($"SELECT COUNT(1) FROM dbo.TBMWF_PROFILE_OBJ_STATE_HISTORY WHERE OBJ_ID = {processId}")
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
if (inHistory > 0)
|
||||
{
|
||||
return new CloseWorkflowResult
|
||||
{
|
||||
ProcessId = processId,
|
||||
IsClosed = false,
|
||||
Outcome = CloseWorkflowOutcome.AlreadyClosed
|
||||
};
|
||||
}
|
||||
|
||||
return new CloseWorkflowResult
|
||||
{
|
||||
ProcessId = 0,
|
||||
@@ -184,12 +161,7 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
|
||||
|
||||
private async Task<int?> TryGetWorkflowMetaCodeAsync(int wisagId, string objectType, CancellationToken cancellationToken)
|
||||
{
|
||||
const string resultType = "json";
|
||||
|
||||
var jsonPayload = await dbContext.Database
|
||||
.SqlQuery<string>($"EXEC dbo.PRCUST_F19_GET_LISTING_DATA @pUSER_ID={wisagId}, @pRESULT_TYPE={resultType}, @pOBJECT_TYPE={objectType}")
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
var jsonPayload = await ExecuteListingJsonAsync(wisagId, objectType, cancellationToken);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jsonPayload))
|
||||
{
|
||||
@@ -200,31 +172,83 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions<Dat
|
||||
{
|
||||
using var document = JsonDocument.Parse(jsonPayload);
|
||||
|
||||
if (!document.RootElement.TryGetProperty("response", out var responseElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!responseElement.TryGetProperty("meta", out var metaElement) || metaElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (metaElement.GetArrayLength() == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!metaElement[0].TryGetProperty("code", out var codeElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return codeElement.GetInt32();
|
||||
return document.RootElement.TryGetProperty("response", out var responseElement)
|
||||
? TryReadMetaCode(responseElement)
|
||||
: null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<WorkflowListingReadModel>> ExecuteListingTableAsync(
|
||||
int wisagId,
|
||||
string objectType,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string resultType = "table";
|
||||
|
||||
return await dbContext
|
||||
.WorkflowListingReadModels
|
||||
.FromSqlInterpolated($"EXEC dbo.PRCUST_F19_GET_LISTING_DATA @pUSER_ID={wisagId}, @pRESULT_TYPE={resultType}, @pOBJECT_TYPE={objectType}")
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> ExecuteListingJsonAsync(int wisagId, string objectType, CancellationToken cancellationToken)
|
||||
{
|
||||
const string commandText = "EXEC dbo.PRCUST_F19_GET_LISTING_DATA @pUSER_ID, @pRESULT_TYPE, @pOBJECT_TYPE";
|
||||
const string resultType = "json";
|
||||
|
||||
await using var command = dbContext.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = commandText;
|
||||
|
||||
command.Parameters.Add(new SqlParameter("@pUSER_ID", wisagId));
|
||||
command.Parameters.Add(new SqlParameter("@pRESULT_TYPE", resultType));
|
||||
command.Parameters.Add(new SqlParameter("@pOBJECT_TYPE", objectType));
|
||||
|
||||
if (command.Connection is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (command.Connection.State != System.Data.ConnectionState.Open)
|
||||
{
|
||||
await command.Connection.OpenAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken) || reader.FieldCount == 0 || reader.IsDBNull(0))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return reader.GetString(0);
|
||||
}
|
||||
|
||||
private static int? TryReadMetaCode(JsonElement responseElement)
|
||||
{
|
||||
if (!responseElement.TryGetProperty("meta", out var metaElement) || metaElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (metaElement.GetArrayLength() == 0 || !metaElement[0].TryGetProperty("code", out var codeElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return codeElement.GetInt32();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<JsonElement> ReadArray(JsonElement sourceElement, string propertyName)
|
||||
{
|
||||
if (!sourceElement.TryGetProperty(propertyName, out var propertyElement) || propertyElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return propertyElement.EnumerateArray().Select(item => item.Clone()).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using DigitalData.TaskFlow.Domain.Common;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.TaskFlow.API.Contracts.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a contract response that contains only metadata.
|
||||
/// </summary>
|
||||
public sealed record MetaOnlyResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets metadata entries.
|
||||
/// </summary>
|
||||
[JsonPropertyName("meta")]
|
||||
public required IReadOnlyList<MetaEntry> Meta { get; init; }
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.TaskFlow.API.Contracts.Workflows;
|
||||
|
||||
/// <summary>
|
||||
@@ -8,15 +10,18 @@ public sealed class CloseWorkflowRequest
|
||||
/// <summary>
|
||||
/// Gets or sets the selected closing method identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("closing_method_id")]
|
||||
public required int ClosingMethodId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional user comment.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional rejection reason identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("rejection_reason_id")]
|
||||
public int? RejectionReasonId { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using DigitalData.TaskFlow.Domain.Common;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.TaskFlow.API.Contracts.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the GET workflows response contract.
|
||||
/// </summary>
|
||||
public sealed record WorkflowsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets metadata entries.
|
||||
/// </summary>
|
||||
[JsonPropertyName("meta")]
|
||||
public required IReadOnlyList<MetaEntry> Meta { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets grouped source blocks.
|
||||
/// </summary>
|
||||
[JsonPropertyName("sources")]
|
||||
public required IReadOnlyList<object> Sources { get; init; }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using DigitalData.TaskFlow.Application.Features.Documents.Queries.GetDocument;
|
||||
using DigitalData.TaskFlow.API.Contracts.Common;
|
||||
using DigitalData.TaskFlow.Domain.Common;
|
||||
using DigitalData.TaskFlow.Domain.Documents;
|
||||
using MediatR;
|
||||
@@ -21,9 +22,11 @@ public sealed class DocumentsController(ISender sender) : ControllerBase
|
||||
/// <returns>Binary stream when available.</returns>
|
||||
[HttpGet("{id}")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status500InternalServerError)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> GetAsync([FromRoute] string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await sender.Send(new GetDocumentQuery(id), cancellationToken);
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using DigitalData.TaskFlow.API.Contracts.Workflows;
|
||||
using DigitalData.TaskFlow.API.Contracts.Common;
|
||||
using DigitalData.TaskFlow.Application.Features.Workflows.Commands.CloseWorkflow;
|
||||
using DigitalData.TaskFlow.Application.Features.Workflows.Queries.GetWorkflows;
|
||||
using DigitalData.TaskFlow.Domain.Closing;
|
||||
using DigitalData.TaskFlow.Domain.Common;
|
||||
using DigitalData.TaskFlow.Domain.Workflows;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -23,15 +21,26 @@ public sealed class WorkflowsController(ISender sender) : ControllerBase
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Workflow response envelope grouped by source.</returns>
|
||||
[HttpGet("{wisagId:int}")]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<WorkflowSourcesResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<ApiEnvelope<WorkflowSourcesResponse>>> GetAsync(
|
||||
[ProducesResponseType(typeof(WorkflowsResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<WorkflowsResponse>> GetAsync(
|
||||
[FromRoute] int wisagId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await sender.Send(new GetWorkflowsQuery(wisagId), cancellationToken);
|
||||
return Ok(response);
|
||||
|
||||
var sources = response.Data?.Sources
|
||||
.Cast<object>()
|
||||
.ToList()
|
||||
?? [];
|
||||
|
||||
return Ok(new WorkflowsResponse
|
||||
{
|
||||
Meta = response.Meta,
|
||||
Sources = sources
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -44,13 +53,14 @@ public sealed class WorkflowsController(ISender sender) : ControllerBase
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Close result envelope.</returns>
|
||||
[HttpPost("{wisagId:int}/{source}/{processId:int}/closing")]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<CloseWorkflowResult>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiEnvelope<object>), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<ApiEnvelope<CloseWorkflowResult>>> CloseAsync(
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status403Forbidden)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<ActionResult<MetaOnlyResponse>> CloseAsync(
|
||||
[FromRoute] int wisagId,
|
||||
[FromRoute] string source,
|
||||
[FromRoute] int processId,
|
||||
@@ -66,6 +76,9 @@ public sealed class WorkflowsController(ISender sender) : ControllerBase
|
||||
request.RejectionReasonId);
|
||||
|
||||
var response = await sender.Send(command, cancellationToken);
|
||||
return Ok(response);
|
||||
return Ok(new MetaOnlyResponse
|
||||
{
|
||||
Meta = response.Meta
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using DigitalData.TaskFlow.API.Contracts.Common;
|
||||
using DigitalData.TaskFlow.Domain.Common;
|
||||
using DigitalData.TaskFlow.Domain.Enums;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DigitalData.TaskFlow.API.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Validates required API key header for API endpoints.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyAuthMiddleware(RequestDelegate next)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Executes middleware logic.
|
||||
/// </summary>
|
||||
/// <param name="context">HTTP context.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Request.Headers.TryGetValue("X-Api-Key", out var values)
|
||||
&& !string.IsNullOrWhiteSpace(values.FirstOrDefault()))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = new MetaOnlyResponse
|
||||
{
|
||||
Meta =
|
||||
[
|
||||
new MetaEntry
|
||||
{
|
||||
Code = (int)MetaCode.InvalidRequest,
|
||||
Message = "Authentifizierung fehlgeschlagen"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
using DigitalData.TaskFlow.Application.Common.Exceptions;
|
||||
using DigitalData.TaskFlow.Application.Common.Factories;
|
||||
using DigitalData.TaskFlow.API.Contracts.Common;
|
||||
using DigitalData.TaskFlow.Domain.Common;
|
||||
using DigitalData.TaskFlow.Domain.Enums;
|
||||
using FluentValidation;
|
||||
@@ -31,7 +31,17 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
|
||||
context.Response.StatusCode = apiException.StatusCode;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = ApiEnvelopeFactory.Create<object?>(null, apiException.MetaCode, apiException.Message);
|
||||
var response = new MetaOnlyResponse
|
||||
{
|
||||
Meta =
|
||||
[
|
||||
new MetaEntry
|
||||
{
|
||||
Code = (int)apiException.MetaCode,
|
||||
Message = apiException.Message
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
|
||||
}
|
||||
catch (ValidationException validationException)
|
||||
@@ -44,7 +54,17 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
|
||||
var message = validationException.Errors.FirstOrDefault()?.ErrorMessage
|
||||
?? "Ungultiger Request";
|
||||
|
||||
var response = ApiEnvelopeFactory.Create<object?>(null, MetaCode.InvalidRequest, message);
|
||||
var response = new MetaOnlyResponse
|
||||
{
|
||||
Meta =
|
||||
[
|
||||
new MetaEntry
|
||||
{
|
||||
Code = (int)MetaCode.InvalidRequest,
|
||||
Message = message
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
|
||||
}
|
||||
catch (NotSupportedException notSupportedException)
|
||||
@@ -54,7 +74,17 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = ApiEnvelopeFactory.Create<object?>(null, MetaCode.InternalError, notSupportedException.Message);
|
||||
var response = new MetaOnlyResponse
|
||||
{
|
||||
Meta =
|
||||
[
|
||||
new MetaEntry
|
||||
{
|
||||
Code = (int)MetaCode.InternalError,
|
||||
Message = notSupportedException.Message
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -64,7 +94,17 @@ public sealed class ExceptionHandlingMiddleware(RequestDelegate next, ILogger<Ex
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = ApiEnvelopeFactory.Create<object?>(null, MetaCode.InternalError);
|
||||
var response = new MetaOnlyResponse
|
||||
{
|
||||
Meta =
|
||||
[
|
||||
new MetaEntry
|
||||
{
|
||||
Code = (int)MetaCode.InternalError,
|
||||
Message = "Interner Fehler"
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.Response.WriteAsync(JsonSerializer.Serialize(response, JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ try
|
||||
var enableSwagger = app.Environment.IsDevelopment() || swaggerOptions.EnabledInProduction;
|
||||
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseMiddleware<ApiKeyAuthMiddleware>();
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
if (enableSwagger)
|
||||
|
||||
Reference in New Issue
Block a user