diff --git a/src/core/DigitalData.TaskFlow.Application/Features/Workflows/Commands/CloseWorkflow/CloseWorkflowCommand.cs b/src/core/DigitalData.TaskFlow.Application/Features/Workflows/Commands/CloseWorkflow/CloseWorkflowCommand.cs index 94ebd3c..47b669a 100644 --- a/src/core/DigitalData.TaskFlow.Application/Features/Workflows/Commands/CloseWorkflow/CloseWorkflowCommand.cs +++ b/src/core/DigitalData.TaskFlow.Application/Features/Workflows/Commands/CloseWorkflow/CloseWorkflowCommand.cs @@ -36,6 +36,7 @@ public sealed class CloseWorkflowCommandValidator : AbstractValidator /// Initializes validation rules for . @@ -57,12 +58,12 @@ public sealed class CloseWorkflowCommandValidator : AbstractValidator 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) diff --git a/src/core/DigitalData.TaskFlow.Application/Features/Workflows/Queries/GetWorkflows/GetWorkflowsQuery.cs b/src/core/DigitalData.TaskFlow.Application/Features/Workflows/Queries/GetWorkflows/GetWorkflowsQuery.cs index aa4449e..271bc88 100644 --- a/src/core/DigitalData.TaskFlow.Application/Features/Workflows/Queries/GetWorkflows/GetWorkflowsQuery.cs +++ b/src/core/DigitalData.TaskFlow.Application/Features/Workflows/Queries/GetWorkflows/GetWorkflowsQuery.cs @@ -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) { diff --git a/src/core/DigitalData.TaskFlow.Domain/Workflows/ContractsSourceBlock.cs b/src/core/DigitalData.TaskFlow.Domain/Workflows/ContractsSourceBlock.cs index 54c2cd9..5c1759a 100644 --- a/src/core/DigitalData.TaskFlow.Domain/Workflows/ContractsSourceBlock.cs +++ b/src/core/DigitalData.TaskFlow.Domain/Workflows/ContractsSourceBlock.cs @@ -1,5 +1,8 @@ namespace DigitalData.TaskFlow.Domain.Workflows; +using System.Text.Json; +using System.Text.Json.Serialization; + /// /// Represents workflow data for the Contracts source system. /// @@ -8,30 +11,30 @@ public sealed record ContractsSourceBlock : SourceBlock /// /// Gets available approval profiles. /// - public required IReadOnlyList Profiles { get; init; } + [JsonPropertyName("profile")] + public required IReadOnlyList Profile { get; init; } /// /// Gets workflow listing items. /// - public required IReadOnlyList Listing { get; init; } + [JsonPropertyName("Listing")] + public required IReadOnlyList Listing { get; init; } /// /// Gets already executed approvals. /// - public required IReadOnlyList Approvals { get; init; } + [JsonPropertyName("approvals")] + public required IReadOnlyList Approvals { get; init; } /// /// Gets document references for workflows. /// - public required IReadOnlyList Documents { get; init; } + [JsonPropertyName("document")] + public required IReadOnlyList Document { get; init; } /// /// Gets available closing methods for workflows. /// - public required IReadOnlyList ClosingMethods { get; init; } - - /// - /// Gets available rejection reasons for rejection-type methods. - /// - public required IReadOnlyList RejectionReasons { get; init; } + [JsonPropertyName("closing_method")] + public required IReadOnlyList ClosingMethod { get; init; } } diff --git a/src/core/DigitalData.TaskFlow.Domain/Workflows/FerdinandSourceBlock.cs b/src/core/DigitalData.TaskFlow.Domain/Workflows/FerdinandSourceBlock.cs index 9872378..4d481a9 100644 --- a/src/core/DigitalData.TaskFlow.Domain/Workflows/FerdinandSourceBlock.cs +++ b/src/core/DigitalData.TaskFlow.Domain/Workflows/FerdinandSourceBlock.cs @@ -1,5 +1,8 @@ namespace DigitalData.TaskFlow.Domain.Workflows; +using System.Text.Json; +using System.Text.Json.Serialization; + /// /// Represents workflow data for the Ferdinand source system. /// @@ -8,30 +11,36 @@ public sealed record FerdinandSourceBlock : SourceBlock /// /// Gets available approval profiles. /// - public required IReadOnlyList Profiles { get; init; } + [JsonPropertyName("profile")] + public required IReadOnlyList Profile { get; init; } /// /// Gets workflow listing items. /// - public required IReadOnlyList Listing { get; init; } + [JsonPropertyName("Listing")] + public required IReadOnlyList Listing { get; init; } /// /// Gets already executed approvals. /// - public required IReadOnlyList Approvals { get; init; } + [JsonPropertyName("approvals")] + public required IReadOnlyList Approvals { get; init; } /// /// Gets document references for workflows. /// - public required IReadOnlyList Documents { get; init; } + [JsonPropertyName("document")] + public required IReadOnlyList Document { get; init; } /// /// Gets available closing methods for workflows. /// - public required IReadOnlyList ClosingMethods { get; init; } + [JsonPropertyName("closing_method")] + public required IReadOnlyList ClosingMethod { get; init; } /// /// Gets available rejection reasons for rejection-type methods. /// - public required IReadOnlyList RejectionReasons { get; init; } + [JsonPropertyName("rejection_reason")] + public required IReadOnlyList RejectionReason { get; init; } } diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/Models/WorkflowListingReadModel.cs b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/Models/WorkflowListingReadModel.cs index ebbe018..f65edb9 100644 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/Models/WorkflowListingReadModel.cs +++ b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Persistence/Models/WorkflowListingReadModel.cs @@ -17,7 +17,7 @@ public sealed class WorkflowListingReadModel /// Gets or sets the profile identifier. /// [Column("profileid")] - public int ProfileId { get; set; } + public byte ProfileId { get; set; } /// /// Gets or sets the voucher type. @@ -53,7 +53,7 @@ public sealed class WorkflowListingReadModel /// Gets or sets the client number. /// [Column("mandantnr")] - public int ClientNumber { get; set; } + public short ClientNumber { get; set; } /// /// Gets or sets the client name. @@ -101,7 +101,7 @@ public sealed class WorkflowListingReadModel /// Gets or sets the priority. /// [Column("priority")] - public int Priority { get; set; } + public byte Priority { get; set; } /// /// Gets or sets the source group text. diff --git a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/WorkflowRepository.cs b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/WorkflowRepository.cs index 1ba4ea2..a27873c 100644 --- a/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/WorkflowRepository.cs +++ b/src/infrastructure/DigitalData.TaskFlow.Infrastructure/Repositories/WorkflowRepository.cs @@ -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, IOptionsGrouped workflow response. public async Task 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(); + 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 }; } /// @@ -124,19 +120,17 @@ public sealed class WorkflowRepository(TaskFlowDbContext dbContext, IOptions EvaluateCloseOutcomeAsync( int wisagId, + string source, int processId, CancellationToken cancellationToken) { - var activeForUser = await dbContext.Database - .SqlQuery($"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($"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($"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 TryGetWorkflowMetaCodeAsync(int wisagId, string objectType, CancellationToken cancellationToken) { - const string resultType = "json"; - - var jsonPayload = await dbContext.Database - .SqlQuery($"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> 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 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 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(); + } } diff --git a/src/presentation/DigitalData.TaskFlow.API/Contracts/Common/MetaOnlyResponse.cs b/src/presentation/DigitalData.TaskFlow.API/Contracts/Common/MetaOnlyResponse.cs new file mode 100644 index 0000000..d7148bf --- /dev/null +++ b/src/presentation/DigitalData.TaskFlow.API/Contracts/Common/MetaOnlyResponse.cs @@ -0,0 +1,16 @@ +using DigitalData.TaskFlow.Domain.Common; +using System.Text.Json.Serialization; + +namespace DigitalData.TaskFlow.API.Contracts.Common; + +/// +/// Represents a contract response that contains only metadata. +/// +public sealed record MetaOnlyResponse +{ + /// + /// Gets metadata entries. + /// + [JsonPropertyName("meta")] + public required IReadOnlyList Meta { get; init; } +} diff --git a/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/CloseWorkflowRequest.cs b/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/CloseWorkflowRequest.cs index 59ea71d..ad8a6ea 100644 --- a/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/CloseWorkflowRequest.cs +++ b/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/CloseWorkflowRequest.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace DigitalData.TaskFlow.API.Contracts.Workflows; /// @@ -8,15 +10,18 @@ public sealed class CloseWorkflowRequest /// /// Gets or sets the selected closing method identifier. /// + [JsonPropertyName("closing_method_id")] public required int ClosingMethodId { get; init; } /// /// Gets or sets an optional user comment. /// + [JsonPropertyName("comment")] public string? Comment { get; init; } /// /// Gets or sets an optional rejection reason identifier. /// + [JsonPropertyName("rejection_reason_id")] public int? RejectionReasonId { get; init; } } diff --git a/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/WorkflowsResponse.cs b/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/WorkflowsResponse.cs new file mode 100644 index 0000000..1e858af --- /dev/null +++ b/src/presentation/DigitalData.TaskFlow.API/Contracts/Workflows/WorkflowsResponse.cs @@ -0,0 +1,22 @@ +using DigitalData.TaskFlow.Domain.Common; +using System.Text.Json.Serialization; + +namespace DigitalData.TaskFlow.API.Contracts.Workflows; + +/// +/// Represents the GET workflows response contract. +/// +public sealed record WorkflowsResponse +{ + /// + /// Gets metadata entries. + /// + [JsonPropertyName("meta")] + public required IReadOnlyList Meta { get; init; } + + /// + /// Gets grouped source blocks. + /// + [JsonPropertyName("sources")] + public required IReadOnlyList Sources { get; init; } +} diff --git a/src/presentation/DigitalData.TaskFlow.API/Controllers/DocumentsController.cs b/src/presentation/DigitalData.TaskFlow.API/Controllers/DocumentsController.cs index b775a05..bea5313 100644 --- a/src/presentation/DigitalData.TaskFlow.API/Controllers/DocumentsController.cs +++ b/src/presentation/DigitalData.TaskFlow.API/Controllers/DocumentsController.cs @@ -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 /// Binary stream when available. [HttpGet("{id}")] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status404NotFound)] - [ProducesResponseType(typeof(ApiEnvelope), 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 GetAsync([FromRoute] string id, CancellationToken cancellationToken) { var response = await sender.Send(new GetDocumentQuery(id), cancellationToken); diff --git a/src/presentation/DigitalData.TaskFlow.API/Controllers/WorkflowsController.cs b/src/presentation/DigitalData.TaskFlow.API/Controllers/WorkflowsController.cs index 82c5712..bce3834 100644 --- a/src/presentation/DigitalData.TaskFlow.API/Controllers/WorkflowsController.cs +++ b/src/presentation/DigitalData.TaskFlow.API/Controllers/WorkflowsController.cs @@ -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 /// Cancellation token. /// Workflow response envelope grouped by source. [HttpGet("{wisagId:int}")] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status500InternalServerError)] - public async Task>> GetAsync( + [ProducesResponseType(typeof(WorkflowsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(MetaOnlyResponse), StatusCodes.Status500InternalServerError)] + public async Task> GetAsync( [FromRoute] int wisagId, CancellationToken cancellationToken) { var response = await sender.Send(new GetWorkflowsQuery(wisagId), cancellationToken); - return Ok(response); + + var sources = response.Data?.Sources + .Cast() + .ToList() + ?? []; + + return Ok(new WorkflowsResponse + { + Meta = response.Meta, + Sources = sources + }); } /// @@ -44,13 +53,14 @@ public sealed class WorkflowsController(ISender sender) : ControllerBase /// Cancellation token. /// Close result envelope. [HttpPost("{wisagId:int}/{source}/{processId:int}/closing")] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status200OK)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status403Forbidden)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status404NotFound)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status409Conflict)] - [ProducesResponseType(typeof(ApiEnvelope), StatusCodes.Status500InternalServerError)] - public async Task>> 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> 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 + }); } } diff --git a/src/presentation/DigitalData.TaskFlow.API/Middleware/ApiKeyAuthMiddleware.cs b/src/presentation/DigitalData.TaskFlow.API/Middleware/ApiKeyAuthMiddleware.cs new file mode 100644 index 0000000..7653f9a --- /dev/null +++ b/src/presentation/DigitalData.TaskFlow.API/Middleware/ApiKeyAuthMiddleware.cs @@ -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; + +/// +/// Validates required API key header for API endpoints. +/// +public sealed class ApiKeyAuthMiddleware(RequestDelegate next) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// Executes middleware logic. + /// + /// HTTP context. + 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)); + } +} diff --git a/src/presentation/DigitalData.TaskFlow.API/Middleware/ExceptionHandlingMiddleware.cs b/src/presentation/DigitalData.TaskFlow.API/Middleware/ExceptionHandlingMiddleware.cs index 6153d37..2ce2c00 100644 --- a/src/presentation/DigitalData.TaskFlow.API/Middleware/ExceptionHandlingMiddleware.cs +++ b/src/presentation/DigitalData.TaskFlow.API/Middleware/ExceptionHandlingMiddleware.cs @@ -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(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(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(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(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)); } } diff --git a/src/presentation/DigitalData.TaskFlow.API/Program.cs b/src/presentation/DigitalData.TaskFlow.API/Program.cs index 4d9d1d5..6f4d115 100644 --- a/src/presentation/DigitalData.TaskFlow.API/Program.cs +++ b/src/presentation/DigitalData.TaskFlow.API/Program.cs @@ -114,6 +114,7 @@ try var enableSwagger = app.Environment.IsDevelopment() || swaggerOptions.EnabledInProduction; app.UseMiddleware(); + app.UseMiddleware(); app.UseSerilogRequestLogging(); if (enableSwagger)