many fixes and improvements - rework for modules/ and common/
feat(emailer): add Postmark and Resend providers
This commit is contained in:
194
backend/Modules/Contents/Features/AddPhotoToAlbum.cs
Normal file
194
backend/Modules/Contents/Features/AddPhotoToAlbum.cs
Normal file
@@ -0,0 +1,194 @@
|
||||
using Hutopy.Infrastructure.BlobStorage.Contracts;
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record AddPhotoToAlbumRequest(
|
||||
Guid AlbumId,
|
||||
Guid PhotoId,
|
||||
IFormFile File,
|
||||
string? Caption = null);
|
||||
|
||||
[PublicAPI]
|
||||
public record AddPhotoToAlbumResponse(
|
||||
Guid PhotoId,
|
||||
string OriginalUrl,
|
||||
string ThumbnailUrl);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class AddPhotoToAlbumRequestValidator : Validator<AddPhotoToAlbumRequest>
|
||||
{
|
||||
private const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10MB
|
||||
private static readonly string[] AllowedImageTypes =
|
||||
[
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp"
|
||||
];
|
||||
|
||||
public AddPhotoToAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.PhotoId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.Must(file => AllowedImageTypes.Contains(file.ContentType))
|
||||
.WithMessage("File must be a valid image (JPEG, PNG, GIF, or WebP)")
|
||||
.Must(file => file.Length <= MaxFileSizeBytes)
|
||||
.WithMessage($"File size must not exceed {MaxFileSizeBytes / 1024 / 1024}MB");
|
||||
|
||||
RuleFor(x => x.Caption)
|
||||
.MaximumLength(255);
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class AddPhotoToAlbumHandler(
|
||||
ContentsDbContext context,
|
||||
IBlobStorage blobStorage)
|
||||
: Endpoint<AddPhotoToAlbumRequest, AddPhotoToAlbumResponse>
|
||||
{
|
||||
private const int MaxThumbnailWidth = 500;
|
||||
private const int MaxThumbnailHeight = 500;
|
||||
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/albums/{AlbumId}/photos");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
AddPhotoToAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
// Fetch the album we want to add photos to
|
||||
var album = await context
|
||||
.Albums
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if a photo with the same ID already exists
|
||||
var existingPhoto = await context
|
||||
.AlbumPhotos
|
||||
.AnyAsync(p => p.Id == request.PhotoId, ct);
|
||||
|
||||
if (existingPhoto)
|
||||
{
|
||||
await SendErrorsAsync(409, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (originalUrl, thumbnailUrl) = await ProcessAndUploadImage(request, ct);
|
||||
|
||||
// Get the next order number
|
||||
var nextOrder = await context
|
||||
.AlbumPhotos
|
||||
.Where(p => p.AlbumId == request.AlbumId)
|
||||
.MaxAsync(p => (int?)p.Order, ct) ?? 0;
|
||||
|
||||
// Create the album photo
|
||||
var photo = new AlbumPhoto
|
||||
{
|
||||
Id = request.PhotoId,
|
||||
CreatedBy = userId,
|
||||
AlbumId = request.AlbumId,
|
||||
OriginalUrl = originalUrl,
|
||||
ThumbnailUrl = thumbnailUrl,
|
||||
Caption = request.Caption,
|
||||
Order = nextOrder + 1
|
||||
};
|
||||
|
||||
context.AlbumPhotos.Add(photo);
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new AddPhotoToAlbumResponse(photo.Id, originalUrl, thumbnailUrl),
|
||||
ct);
|
||||
}
|
||||
catch (UnknownImageFormatException)
|
||||
{
|
||||
await SendStringAsync("Invalid image format", 400, cancellation: ct);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await SendStringAsync("Error processing image", 500, cancellation: ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(string originalUrl, string thumbnailUrl)> ProcessAndUploadImage(
|
||||
AddPhotoToAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var originalFileName = Path.GetFileName(request.File.FileName);
|
||||
var nameWithoutExt = Path.GetFileNameWithoutExtension(originalFileName);
|
||||
var extension = Path.GetExtension(originalFileName);
|
||||
|
||||
var filenameOriginal = $"{nameWithoutExt}{extension}";
|
||||
var filenameThumbnail = $"{nameWithoutExt}.thumbnail{extension}";
|
||||
|
||||
var blobOriginal = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameOriginal}";
|
||||
var blobThumbnail = $"{SubDirectoryNames.Albums}/{request.AlbumId}/{filenameThumbnail}";
|
||||
|
||||
// Process the original image
|
||||
await using var originalStream = request.File.OpenReadStream();
|
||||
using var image = await Image.LoadAsync(originalStream, ct);
|
||||
|
||||
// Calculate target size while preserving the original aspect ratio
|
||||
var originalWidth = image.Width;
|
||||
var originalHeight = image.Height;
|
||||
|
||||
double ratioX = (double)MaxThumbnailWidth / originalWidth;
|
||||
double ratioY = (double)MaxThumbnailHeight / originalHeight;
|
||||
double ratio = Math.Min(ratioX, ratioY);
|
||||
|
||||
int newWidth = (int)(originalWidth * ratio);
|
||||
int newHeight = (int)(originalHeight * ratio);
|
||||
|
||||
// Create thumbnail
|
||||
using var thumbnailStream = new MemoryStream();
|
||||
image.Mutate(x => x.Resize(newWidth, newHeight));
|
||||
await image.SaveAsync(thumbnailStream, image.Metadata.DecodedImageFormat!, ct);
|
||||
thumbnailStream.Position = 0;
|
||||
|
||||
// Upload both versions
|
||||
var originalUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
blobOriginal,
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
var thumbnailUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Creators,
|
||||
blobThumbnail,
|
||||
thumbnailStream,
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
return (originalUrl, thumbnailUrl);
|
||||
}
|
||||
}
|
||||
75
backend/Modules/Contents/Features/CreateAlbum.cs
Normal file
75
backend/Modules/Contents/Features/CreateAlbum.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record CreateAlbumRequest(
|
||||
Guid AlbumId,
|
||||
string Title,
|
||||
string? Description = null);
|
||||
|
||||
[PublicAPI]
|
||||
public record CreateAlbumResponse(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class CreateAlbumRequestValidator : Validator<CreateAlbumRequest>
|
||||
{
|
||||
public CreateAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.Title)
|
||||
.NotNull()
|
||||
.NotEmpty()
|
||||
.MaximumLength(255);
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000);
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class CreateAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<CreateAlbumRequest, CreateAlbumResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/albums");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
CreateAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Check if an album with the same ID already exists
|
||||
var existingAlbum = await context
|
||||
.Albums
|
||||
.AnyAsync(a => a.Id == request.AlbumId, ct);
|
||||
|
||||
if (existingAlbum)
|
||||
{
|
||||
await SendErrorsAsync(409, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var album = new Album
|
||||
{
|
||||
Id = request.AlbumId,
|
||||
CreatedBy = User.GetUserId(),
|
||||
Title = request.Title
|
||||
};
|
||||
|
||||
context.Albums.Add(album);
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(
|
||||
new CreateAlbumResponse(album.Id),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
83
backend/Modules/Contents/Features/GetAlbum.cs
Normal file
83
backend/Modules/Contents/Features/GetAlbum.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record GetAlbumRequest(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public record AlbumPhotoDto(
|
||||
Guid Id,
|
||||
string OriginalUrl,
|
||||
string ThumbnailUrl,
|
||||
string? Caption,
|
||||
int Order,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
[PublicAPI]
|
||||
public record GetAlbumResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
IReadOnlyList<AlbumPhotoDto> Photos,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class GetAlbumRequestValidator : Validator<GetAlbumRequest>
|
||||
{
|
||||
public GetAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class GetAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<GetAlbumRequest, GetAlbumResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
AllowAnonymous();
|
||||
Get("/api/albums/{AlbumId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
GetAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos.OrderBy(p => p.Order))
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var photos = album.Photos
|
||||
.Select(p => new AlbumPhotoDto(
|
||||
p.Id,
|
||||
p.OriginalUrl,
|
||||
p.ThumbnailUrl,
|
||||
p.Caption,
|
||||
p.Order,
|
||||
p.CreatedAt))
|
||||
.ToList();
|
||||
|
||||
await SendOkAsync(
|
||||
new GetAlbumResponse(
|
||||
album.Id,
|
||||
album.Title,
|
||||
photos,
|
||||
album.CreatedAt),
|
||||
ct);
|
||||
}
|
||||
}
|
||||
66
backend/Modules/Contents/Features/RemoveAlbum.cs
Normal file
66
backend/Modules/Contents/Features/RemoveAlbum.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RemoveAlbumRequest(
|
||||
Guid AlbumId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RemoveAlbumRequestValidator : Validator<RemoveAlbumRequest>
|
||||
{
|
||||
public RemoveAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class RemoveAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<RemoveAlbumRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/albums/{AlbumId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RemoveAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos)
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete the album
|
||||
album.DeletedBy = userId;
|
||||
album.DeletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
// Soft delete all photos in the album
|
||||
foreach (var photo in album.Photos)
|
||||
{
|
||||
photo.DeletedBy = userId;
|
||||
photo.DeletedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendNoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
73
backend/Modules/Contents/Features/RemovePhotoFromAlbum.cs
Normal file
73
backend/Modules/Contents/Features/RemovePhotoFromAlbum.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using Hutopy.Infrastructure.Security;
|
||||
using Hutopy.Modules.Contents.Data;
|
||||
|
||||
namespace Hutopy.Modules.Contents.Features;
|
||||
|
||||
[PublicAPI]
|
||||
public record RemovePhotoFromAlbumRequest(
|
||||
Guid AlbumId,
|
||||
Guid PhotoId);
|
||||
|
||||
[PublicAPI]
|
||||
public sealed class RemovePhotoFromAlbumRequestValidator : Validator<RemovePhotoFromAlbumRequest>
|
||||
{
|
||||
public RemovePhotoFromAlbumRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AlbumId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
|
||||
RuleFor(x => x.PhotoId)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
[PublicAPI]
|
||||
public class RemovePhotoFromAlbumHandler(
|
||||
ContentsDbContext context)
|
||||
: Endpoint<RemovePhotoFromAlbumRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/albums/{AlbumId}/photos/{PhotoId}");
|
||||
Options(o => o.WithTags("Albums"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(
|
||||
RemovePhotoFromAlbumRequest request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var album = await context
|
||||
.Albums
|
||||
.Include(a => a.Photos)
|
||||
.SingleOrDefaultAsync(
|
||||
a => a.Id == request.AlbumId && a.CreatedBy == userId,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (album is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var photo = album.Photos
|
||||
.SingleOrDefault(p => p.Id == request.PhotoId);
|
||||
|
||||
if (photo is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Soft delete the photo
|
||||
photo.DeletedBy = userId;
|
||||
photo.DeletedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
await SendNoContentAsync(ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user