refactor(application): update workflow queries and exception handling

This commit is contained in:
2026-10-31 17:09:16 +01:00
parent 12cb46d054
commit 11ebb0567a
18 changed files with 102 additions and 517 deletions

View File

@@ -1,32 +0,0 @@
using DigitalData.TaskFlow.Domain.Enums;
namespace DigitalData.TaskFlow.Application.Common.Exceptions;
/// <summary>
/// Represents an API-facing exception that carries HTTP and domain meta information.
/// </summary>
public class ApiException : Exception
{
/// <summary>
/// Initializes a new instance of the <see cref="ApiException"/> class.
/// </summary>
/// <param name="statusCode">HTTP status code to return.</param>
/// <param name="metaCode">Domain meta code to return.</param>
/// <param name="message">Response message.</param>
public ApiException(int statusCode, MetaCode metaCode, string message)
: base(message)
{
StatusCode = statusCode;
MetaCode = metaCode;
}
/// <summary>
/// Gets the HTTP status code.
/// </summary>
public int StatusCode { get; }
/// <summary>
/// Gets the domain meta code.
/// </summary>
public MetaCode MetaCode { get; }
}

View File

@@ -1,19 +0,0 @@
using DigitalData.TaskFlow.Domain.Enums;
namespace DigitalData.TaskFlow.Application.Common.Exceptions;
/// <summary>
/// Represents a 409 API exception.
/// </summary>
public sealed class ConflictApiException : ApiException
{
/// <summary>
/// Initializes a new instance of the <see cref="ConflictApiException"/> class.
/// </summary>
/// <param name="metaCode">Domain meta code to return.</param>
/// <param name="message">Response message.</param>
public ConflictApiException(MetaCode metaCode, string message)
: base(409, metaCode, message)
{
}
}

View File

@@ -1,18 +0,0 @@
using DigitalData.TaskFlow.Domain.Enums;
namespace DigitalData.TaskFlow.Application.Common.Exceptions;
/// <summary>
/// Represents a 403 API exception.
/// </summary>
public sealed class ForbiddenApiException : ApiException
{
/// <summary>
/// Initializes a new instance of the <see cref="ForbiddenApiException"/> class.
/// </summary>
/// <param name="message">Response message.</param>
public ForbiddenApiException(string message)
: base(403, MetaCode.NoAccess, message)
{
}
}

View File

@@ -1,18 +0,0 @@
using DigitalData.TaskFlow.Domain.Enums;
namespace DigitalData.TaskFlow.Application.Common.Exceptions;
/// <summary>
/// Represents a 400 API exception.
/// </summary>
public sealed class InvalidRequestApiException : ApiException
{
/// <summary>
/// Initializes a new instance of the <see cref="InvalidRequestApiException"/> class.
/// </summary>
/// <param name="message">Response message.</param>
public InvalidRequestApiException(string message)
: base(400, MetaCode.InvalidRequest, message)
{
}
}

View File

@@ -0,0 +1,5 @@
namespace DigitalData.TaskFlow.Application.Common.Exceptions;
public sealed class InvalidWorkflowPayloadException(string message) : Exception(message)
{
}

View File

@@ -1,18 +0,0 @@
using DigitalData.TaskFlow.Domain.Enums;
namespace DigitalData.TaskFlow.Application.Common.Exceptions;
/// <summary>
/// Represents a 404 API exception.
/// </summary>
public sealed class NotFoundApiException : ApiException
{
/// <summary>
/// Initializes a new instance of the <see cref="NotFoundApiException"/> class.
/// </summary>
/// <param name="message">Response message.</param>
public NotFoundApiException(string message)
: base(404, MetaCode.NotFound, message)
{
}
}

View File

@@ -1,49 +0,0 @@
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Enums;
namespace DigitalData.TaskFlow.Application.Common.Factories;
/// <summary>
/// Creates standard API envelope instances with centralized meta code/message handling.
/// </summary>
public static class ApiEnvelopeFactory
{
/// <summary>
/// Creates a successful or business-result response envelope.
/// </summary>
/// <typeparam name="T">Payload type.</typeparam>
/// <param name="data">Payload data.</param>
/// <param name="code">Business result code.</param>
/// <param name="message">Optional custom message. If not supplied, a default message is resolved from <paramref name="code"/>.</param>
/// <returns>Response envelope containing meta and payload.</returns>
public static ApiEnvelope<T> Create<T>(T data, MetaCode code = MetaCode.Ok, string? message = null)
{
return new ApiEnvelope<T>
{
Meta =
[
new MetaEntry
{
Code = (int)code,
Message = message ?? ResolveDefaultMessage(code)
}
],
Data = data
};
}
private static string ResolveDefaultMessage(MetaCode code)
{
return code switch
{
MetaCode.Ok => "OK",
MetaCode.NoOpenWorkflows => "Keine offenen Workflows",
MetaCode.NoAccess => "Kein Zugriff",
MetaCode.InvalidRequest => "Ungultiger Request",
MetaCode.InternalError => "Interner Fehler",
MetaCode.NotFound => "Vorgang nicht gefunden",
MetaCode.AlreadyClosed => "Vorgang bereits abgeschlossen",
_ => "Interner Fehler"
};
}
}

View File

@@ -1,17 +1,13 @@
using DigitalData.TaskFlow.Domain.Documents;
using DigitalData.TaskFlow.Domain.ProcedureResults;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DigitalData.TaskFlow.Application.Common.Interfaces;
/// <summary>
/// Defines document-specific data access operations.
/// </summary>
public interface IDocumentRepository : IRepository
public interface IDocumentRepository
{
/// <summary>
/// Gets document binary content for the given document identifier.
/// </summary>
/// <param name="id">Document identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Binary document payload when found; otherwise <see langword="null"/>.</returns>
Task<DocumentBinaryResult?> GetAsync(string id, CancellationToken cancellationToken);
Task<Document?> GetAsync(int userId, long processId, long docId, CancellationToken cancellationToken);
}

View File

@@ -1,5 +1,4 @@
using DigitalData.TaskFlow.Domain.Closing;
using DigitalData.TaskFlow.Domain.Workflows;
using DigitalData.TaskFlow.Domain.ProcedureResults;
namespace DigitalData.TaskFlow.Application.Common.Interfaces;
@@ -14,21 +13,5 @@ public interface IWorkflowRepository : IRepository
/// <param name="wisagId">WISAG user identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Workflow data grouped by source.</returns>
Task<WorkflowSourcesResponse> GetAsync(int wisagId, CancellationToken cancellationToken);
/// <summary>
/// Applies a closing decision to a workflow process.
/// </summary>
/// <param name="wisagId">WISAG user identifier.</param>
/// <param name="source">Source system name.</param>
/// <param name="processId">Process identifier.</param>
/// <param name="decision">Closing decision payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Closing operation result.</returns>
Task<CloseWorkflowResult> CloseAsync(
int wisagId,
string source,
int processId,
CloseWorkflowDecision decision,
CancellationToken cancellationToken);
Task<Workflow?> GetByWisagIdAsync(int wisagId, CancellationToken cancellationToken);
}

View File

@@ -1,19 +0,0 @@
using AutoMapper;
using DigitalData.TaskFlow.Application.Features.Workflows.Commands.CloseWorkflow;
using DigitalData.TaskFlow.Domain.Closing;
namespace DigitalData.TaskFlow.Application.Common.Mappings;
/// <summary>
/// Defines AutoMapper configuration for application-level mappings.
/// </summary>
public sealed class ApplicationMappingProfile : Profile
{
/// <summary>
/// Initializes mapping configuration.
/// </summary>
public ApplicationMappingProfile()
{
CreateMap<CloseWorkflowCommand, CloseWorkflowDecision>();
}
}

View File

@@ -1,5 +1,4 @@
using DigitalData.TaskFlow.Application.Options;
using FluentValidation;
using FluentValidation;
using MediatR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -10,29 +9,30 @@ namespace DigitalData.TaskFlow.Application;
public static class DependencyInjection
{
public const string LuckyPennySoftLicenseKeySection = "LuckyPennySoftLicenseKey";
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
{
var mediatROptions = configuration
.GetSection(MediatROptions.SectionName)
.Get<MediatROptions>();
var lpSoftLicenseKey = configuration[LuckyPennySoftLicenseKeySection];
var hasLpSoftLicenseKey = !string.IsNullOrWhiteSpace(lpSoftLicenseKey);
services
.AddOptions<MediatROptions>()
.Bind(configuration.GetSection(MediatROptions.SectionName));
var assembly = Assembly.GetExecutingAssembly();
services.AddMediatR(config =>
{
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly);
config.RegisterServicesFromAssembly(assembly);
if (!string.IsNullOrWhiteSpace(mediatROptions?.LuckyPennySoftLicenseKey))
{
config.LicenseKey = mediatROptions.LuckyPennySoftLicenseKey;
}
if (hasLpSoftLicenseKey)
config.LicenseKey = lpSoftLicenseKey;
});
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
services.AddValidatorsFromAssembly(assembly);
services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddAutoMapper(_ => { }, Assembly.GetExecutingAssembly());
services.AddAutoMapper(config =>
{
if (hasLpSoftLicenseKey)
config.LicenseKey = lpSoftLicenseKey;
}, assembly);
return services;
}

View File

@@ -1,56 +0,0 @@
using DigitalData.TaskFlow.Application.Common.Factories;
using DigitalData.TaskFlow.Application.Common.Exceptions;
using DigitalData.TaskFlow.Application.Common.Interfaces;
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Documents;
using DigitalData.TaskFlow.Domain.Enums;
using FluentValidation;
using MediatR;
namespace DigitalData.TaskFlow.Application.Features.Documents.Queries.GetDocument;
/// <summary>
/// Represents a request to retrieve binary document content by document identifier.
/// </summary>
/// <param name="Id">Document identifier.</param>
public sealed record GetDocumentQuery(string Id) : IRequest<ApiEnvelope<DocumentBinaryResult>>;
/// <summary>
/// Validates <see cref="GetDocumentQuery"/> requests.
/// </summary>
public sealed class GetDocumentQueryValidator : AbstractValidator<GetDocumentQuery>
{
/// <summary>
/// Initializes validation rules for <see cref="GetDocumentQuery"/>.
/// </summary>
public GetDocumentQueryValidator()
{
RuleFor(x => x.Id)
.NotEmpty();
}
}
/// <summary>
/// Handles <see cref="GetDocumentQuery"/> requests.
/// </summary>
public sealed class GetDocumentQueryHandler(IDocumentRepository documentRepository)
: IRequestHandler<GetDocumentQuery, ApiEnvelope<DocumentBinaryResult>>
{
/// <summary>
/// Executes the query and returns binary document data in the standard API envelope.
/// </summary>
/// <param name="request">Query request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Document response envelope.</returns>
public async Task<ApiEnvelope<DocumentBinaryResult>> Handle(GetDocumentQuery request, CancellationToken cancellationToken)
{
var document = await documentRepository.GetAsync(request.Id, cancellationToken);
if (document is null)
{
throw new NotFoundApiException("Dokument nicht gefunden");
}
return ApiEnvelopeFactory.Create(document);
}
}

View File

@@ -0,0 +1,37 @@
using DigitalData.TaskFlow.Application.Common.Exceptions;
using DigitalData.TaskFlow.Application.Common.Interfaces;
using MediatR;
using System.Text.Json;
namespace DigitalData.TaskFlow.Application.Features.Documents.Queries;
/// <summary>
///
/// </summary>
/// <param name="UserId"></param>
/// <param name="ProcessId"></param>
/// <param name="Id"></param>
public sealed record GetDocumentQuery(int UserId, long ProcessId, long Id) : IRequest<JsonElement>;
public sealed class GetDocumentQueryHandler(IDocumentRepository Repo) : IRequestHandler<GetDocumentQuery, JsonElement>
{
public async Task<JsonElement> Handle(GetDocumentQuery request, CancellationToken cancellationToken)
{
var document = await Repo.GetAsync(userId: request.UserId, processId: request.ProcessId, docId: request.Id, cancellationToken);
if (!string.IsNullOrWhiteSpace(document?.Result))
{
using var doc = JsonDocument.Parse(document.Result);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("response", out var response))
{
return response.Clone();
}
}
throw new InvalidWorkflowPayloadException(
"Stored procedure returned an invalid payload. Expected a JSON object with a 'response' property.");
}
}

View File

@@ -1,126 +0,0 @@
using DigitalData.TaskFlow.Application.Common.Exceptions;
using DigitalData.TaskFlow.Application.Common.Interfaces;
using DigitalData.TaskFlow.Application.Common.Factories;
using AutoMapper;
using DigitalData.TaskFlow.Domain.Closing;
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Enums;
using FluentValidation;
using MediatR;
namespace DigitalData.TaskFlow.Application.Features.Workflows.Commands.CloseWorkflow;
/// <summary>
/// Represents a request to close a workflow process using a selected closing method.
/// </summary>
/// <param name="WisagId">WISAG user identifier.</param>
/// <param name="Source">Source system name.</param>
/// <param name="ProcessId">Process identifier.</param>
/// <param name="ClosingMethodId">Selected closing method identifier.</param>
/// <param name="Comment">Optional user comment.</param>
/// <param name="RejectionReasonId">Optional rejection reason identifier.</param>
public sealed record CloseWorkflowCommand(
int WisagId,
string Source,
int ProcessId,
int ClosingMethodId,
string? Comment,
int? RejectionReasonId)
: IRequest<ApiEnvelope<CloseWorkflowResult>>;
/// <summary>
/// Validates <see cref="CloseWorkflowCommand"/> requests.
/// </summary>
public sealed class CloseWorkflowCommandValidator : AbstractValidator<CloseWorkflowCommand>
{
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"/>.
/// </summary>
public CloseWorkflowCommandValidator()
{
RuleFor(x => x.WisagId)
.GreaterThan(0);
RuleFor(x => x.ProcessId)
.GreaterThan(0);
RuleFor(x => x.ClosingMethodId)
.GreaterThan(0);
RuleFor(x => x.Source)
.NotEmpty()
.Must(source => AllowedSources.Contains(source))
.WithMessage("Source must be one of the supported source systems.");
RuleFor(x => x.ClosingMethodId)
.Must(methodId => methodId is ApproveMethodId or RejectMethodId or BackToPerMethodId)
.WithMessage("Unbekannte closing_method_id");
RuleFor(x => x.Comment)
.NotEmpty()
.When(x => x.ClosingMethodId is RejectMethodId or BackToPerMethodId)
.WithMessage("Kommentar ist fur diese closing_method Pflicht");
RuleFor(x => x.RejectionReasonId)
.NotNull()
.When(x => x.ClosingMethodId == RejectMethodId)
.WithMessage("rejection_reason_id ist fur diese closing_method Pflicht");
}
}
/// <summary>
/// Handles <see cref="CloseWorkflowCommand"/> requests.
/// </summary>
public sealed class CloseWorkflowCommandHandler(IWorkflowRepository workflowRepository, IMapper mapper)
: IRequestHandler<CloseWorkflowCommand, ApiEnvelope<CloseWorkflowResult>>
{
/// <summary>
/// Executes the closing request and returns the operation result in the standard API envelope.
/// </summary>
/// <param name="request">Command request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Closing result envelope.</returns>
public async Task<ApiEnvelope<CloseWorkflowResult>> Handle(CloseWorkflowCommand request, CancellationToken cancellationToken)
{
if (string.Equals(request.Source, "Contracts", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidRequestApiException("Quelle 'Contracts' wird fur Closing aktuell nicht unterstutzt");
}
var decision = mapper.Map<CloseWorkflowDecision>(request);
var result = await workflowRepository.CloseAsync(
request.WisagId,
request.Source,
request.ProcessId,
decision,
cancellationToken);
if (result.Outcome == CloseWorkflowOutcome.NoAccess)
{
throw new ForbiddenApiException("Kein Zugriff");
}
if (result.Outcome == CloseWorkflowOutcome.NotImplemented)
{
throw new InvalidOperationException("Closing integration is not finalized yet.");
}
if (result.ProcessId <= 0)
{
throw new NotFoundApiException("Vorgang nicht gefunden");
}
if (!result.IsClosed)
{
throw new ConflictApiException(MetaCode.AlreadyClosed, "Vorgang bereits abgeschlossen");
}
return ApiEnvelopeFactory.Create(result);
}
}

View File

@@ -1,67 +0,0 @@
using DigitalData.TaskFlow.Application.Common.Factories;
using DigitalData.TaskFlow.Application.Common.Interfaces;
using DigitalData.TaskFlow.Domain.Common;
using DigitalData.TaskFlow.Domain.Enums;
using DigitalData.TaskFlow.Domain.Workflows;
using FluentValidation;
using MediatR;
namespace DigitalData.TaskFlow.Application.Features.Workflows.Queries.GetWorkflows;
/// <summary>
/// Represents a request to fetch grouped workflow data for a WISAG user.
/// </summary>
/// <param name="WisagId">WISAG user identifier.</param>
public sealed record GetWorkflowsQuery(int WisagId) : IRequest<ApiEnvelope<WorkflowSourcesResponse>>;
/// <summary>
/// Validates <see cref="GetWorkflowsQuery"/> requests.
/// </summary>
public sealed class GetWorkflowsQueryValidator : AbstractValidator<GetWorkflowsQuery>
{
/// <summary>
/// Initializes validation rules for <see cref="GetWorkflowsQuery"/>.
/// </summary>
public GetWorkflowsQueryValidator()
{
RuleFor(x => x.WisagId)
.GreaterThan(0);
}
}
/// <summary>
/// Handles <see cref="GetWorkflowsQuery"/> requests.
/// </summary>
public sealed class GetWorkflowsQueryHandler(IWorkflowRepository workflowRepository)
: IRequestHandler<GetWorkflowsQuery, ApiEnvelope<WorkflowSourcesResponse>>
{
/// <summary>
/// Executes the query and returns a workflow payload wrapped in the standard API envelope.
/// </summary>
/// <param name="request">Query request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Workflow response envelope.</returns>
public async Task<ApiEnvelope<WorkflowSourcesResponse>> Handle(GetWorkflowsQuery request, CancellationToken cancellationToken)
{
var data = await workflowRepository.GetAsync(request.WisagId, cancellationToken);
if (data.Sources.Count == 0)
{
return ApiEnvelopeFactory.Create(data, MetaCode.NoAccess);
}
var hasListingData = data.Sources.Any(source => source switch
{
FerdinandSourceBlock ferdinand => ferdinand.Listing.Count > 0,
ContractsSourceBlock contracts => contracts.Listing.Count > 0,
_ => false
});
if (!hasListingData)
{
return ApiEnvelopeFactory.Create(data, MetaCode.NoOpenWorkflows);
}
return ApiEnvelopeFactory.Create(data);
}
}

View File

@@ -0,0 +1,35 @@
using DigitalData.TaskFlow.Application.Common.Exceptions;
using DigitalData.TaskFlow.Application.Common.Interfaces;
using MediatR;
using System.Text.Json;
namespace DigitalData.TaskFlow.Application.Features.Workflows.Queries;
/// <summary>
/// Represents a request to fetch grouped workflow data for a WISAG user.
/// </summary>
/// <param name="WisagId">WISAG user identifier.</param>
public sealed record GetWorkflowsQuery(int WisagId) : IRequest<JsonElement>;
public sealed class GetWorkflowsQueryHandler(IWorkflowRepository Repo) : IRequestHandler<GetWorkflowsQuery, JsonElement>
{
public async Task<JsonElement> Handle(GetWorkflowsQuery request, CancellationToken cancellationToken)
{
var workflow = await Repo.GetByWisagIdAsync(request.WisagId, cancellationToken);
if (!string.IsNullOrWhiteSpace(workflow?.Result))
{
using var document = JsonDocument.Parse(workflow.Result);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object &&
root.TryGetProperty("response", out var response))
{
return response.Clone();
}
}
throw new InvalidWorkflowPayloadException(
"Stored procedure returned an invalid payload. Expected a JSON object with a 'response' property.");
}
}

View File

@@ -1,8 +0,0 @@
namespace DigitalData.TaskFlow.Application.Options;
public sealed class MediatROptions
{
public const string SectionName = "MediatR";
public string? LuckyPennySoftLicenseKey { get; init; }
}

View File

@@ -1,41 +0,0 @@
# DigitalData.TaskFlow.Application
Dieses Projekt enthaelt die Use-Case-Schicht der Anwendung.
## Zweck
Die Application-Schicht orchestriert fachliche Anwendungsfaelle. Sie nimmt Requests entgegen, validiert diese, ruft Domain-nahe Abstraktionen auf und liefert strukturierte Ergebnisse zurueck.
## Aktueller Inhalt
- `DependencyInjection.cs`
- Registriert zentrale Application-Dienste:
- MediatR
- FluentValidation
- AutoMapper
- Bindet `MediatR`-Optionen inklusive Lizenzschluessel.
- `Options/`
- `MediatROptions`: Konfiguration fuer MediatR (z. B. `LuckyPennySoftLicenseKey`).
## Geplante Inhalte (naechste Phasen)
- `Features/`
- Queries/Commands fuer Workflows, Closing und Documents.
- `Validators/`
- FluentValidation-Regeln pro Request.
- `Common/Behaviors/`
- Pipeline-Behavior (z. B. zentrale Request-Validierung).
- `Common/Interfaces/`
- Repository-Abstraktionen fuer Datenzugriff.
- `Mappings/`
- AutoMapper-Profile zwischen Infrastrukturmodellen und Domainmodellen.
## Architektur-Regeln
- Keine direkten SQL- oder EF-Core-Aufrufe.
- Keine HTTP-spezifischen Controller-Abhaengigkeiten.
- Kommunikation nach aussen nur ueber Interfaces.
## Rolle im Gesamtsystem
Diese Schicht ist der zentrale Ausfuehrungspunkt fuer Use-Cases und verbindet die API-Schicht mit Domain und Infrastructure, ohne Technologie-Details nach oben durchzureichen.