feat(stamp): Add Application layer (AddStampCommand with handler/validator)

- Vertical slice: Command + Handler + Validator in single file
- Validation rules enforce StampType-specific required fields
- Text stamps: Text + FontName + FontSize required
- Image stamps: ImageBytes required
- Predefined stamps: PredefinedType required
- Supports Origin, Rotation, Opacity, Placement, Size parameters
This commit is contained in:
2026-07-21 14:39:03 +02:00
parent c037ad8446
commit d0606f3605

View File

@@ -0,0 +1,125 @@
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.AddStamp;
/// <summary>
/// Command to add a stamp (text, image, or predefined) to PDF pages.
/// Combines Command, Handler, and Validator in a single file (Vertical Slice pattern).
/// </summary>
public record AddStampCommand : IRequest<byte[]>
{
public required Stream PdfStream { get; init; }
public required Domain.Models.ValueObjects.StampType StampType { get; init; }
public int[]? PageNumbers { get; init; } // null = all pages
public required (double X, double Y) Position { get; init; }
public (double Width, double Height)? Size { get; init; }
public Domain.Models.ValueObjects.AnnotationOrigin Origin { get; init; } = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft;
public string? Text { get; init; }
public string? FontName { get; init; }
public double? FontSize { get; init; }
public string? Color { get; init; }
public double? Opacity { get; init; }
public double? Rotation { get; init; }
public Domain.Models.ValueObjects.StampPlacement Placement { get; init; } = Domain.Models.ValueObjects.StampPlacement.Foreground;
public byte[]? ImageBytes { get; init; }
public Domain.Models.ValueObjects.PredefinedStampType? PredefinedType { get; init; }
}
/// <summary>
/// Handler for AddStampCommand.
/// </summary>
public class AddStampHandler(Common.Interfaces.IPdfProcessor pdfProcessor) : IRequestHandler<AddStampCommand, byte[]>
{
public async Task<byte[]> Handle(AddStampCommand command, CancellationToken cancellationToken)
{
return await pdfProcessor.AddStampAsync(
command.PdfStream,
command.StampType,
command.PageNumbers,
command.Position,
command.Size,
command.Origin,
command.Text,
command.FontName,
command.FontSize,
command.Color,
command.Opacity,
command.Rotation,
command.Placement,
command.ImageBytes,
command.PredefinedType
);
}
}
/// <summary>
/// Validator for AddStampCommand.
/// </summary>
public class AddStampValidator : AbstractValidator<AddStampCommand>
{
public AddStampValidator()
{
RuleFor(x => x.PdfStream)
.NotNull().WithMessage("PDF stream is required");
RuleFor(x => x.Position.X)
.GreaterThanOrEqualTo(0).WithMessage("Position X must be >= 0");
RuleFor(x => x.Position.Y)
.GreaterThanOrEqualTo(0).WithMessage("Position Y must be >= 0");
// Text stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Text, () =>
{
RuleFor(x => x.Text)
.NotEmpty().WithMessage("Text is required for Text stamp type");
});
// Image stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Image, () =>
{
RuleFor(x => x.ImageBytes)
.NotNull().WithMessage("Image bytes are required for Image stamp type")
.Must(bytes => bytes != null && bytes.Length > 0).WithMessage("Image bytes cannot be empty");
});
// Predefined stamp validation
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Predefined, () =>
{
RuleFor(x => x.PredefinedType)
.NotNull().WithMessage("Predefined type is required for Predefined stamp type");
});
// Optional parameter validations
When(x => x.Opacity.HasValue, () =>
{
RuleFor(x => x.Opacity!.Value)
.InclusiveBetween(0.0, 1.0).WithMessage("Opacity must be between 0.0 and 1.0");
});
When(x => x.Rotation.HasValue, () =>
{
RuleFor(x => x.Rotation!.Value)
.InclusiveBetween(0.0, 360.0).WithMessage("Rotation must be between 0 and 360 degrees");
});
When(x => x.FontSize.HasValue, () =>
{
RuleFor(x => x.FontSize!.Value)
.GreaterThan(0).WithMessage("Font size must be positive");
});
When(x => !string.IsNullOrWhiteSpace(x.Color), () =>
{
RuleFor(x => x.Color!)
.Matches(@"^[0-9A-Fa-f]{6}$").WithMessage("Color must be 6-digit hex (e.g., 'FF0000')");
});
When(x => x.PageNumbers != null, () =>
{
RuleFor(x => x.PageNumbers!)
.Must(pages => pages.All(p => p > 0)).WithMessage("All page numbers must be >= 1");
});
}
}