From f5e20c1fb22b30d1e5ef07194c79aa9590c5ce28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Fri, 6 Mar 2026 23:14:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=B7=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=83=D1=81=D0=BB=D1=83?= =?UTF-8?q?=D0=B3=D0=B0=D1=85,=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B5,=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D1=83=20=D1=83=D1=81=D0=BB?= =?UTF-8?q?=D1=83=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Host/Endpoints/SearchEndpoints.cs | 17 +- .../Commands/CreateOfferCommand.cs | 8 +- .../Commands/DeleteOfferCommand.cs | 32 ++++ .../Commands/ToggleOfferStatusCommand.cs | 32 ++++ .../Commands/UpdateOfferCommand.cs | 48 ++++++ .../Commands/UploadOfferImageCommand.cs | 43 +++++ .../Catalog/Application/Common/Dtos.cs | 11 +- .../Application/Queries/GetMyOffersQuery.cs | 3 +- .../Application/Queries/GetOfferByIdQuery.cs | 3 +- .../Catalog/Domain/Aggregates/Offer.cs | 42 ++++- .../Domain/Repositories/IOfferRepository.cs | 1 + ...54_AddOfferImagesAndSoftDelete.Designer.cs | 148 ++++++++++++++++++ ...60306185654_AddOfferImagesAndSoftDelete.cs | 45 ++++++ .../CatalogDbContextModelSnapshot.cs | 8 + .../Repositories/OfferRepository.cs | 6 + .../Endpoints/CatalogEndpoints.cs | 44 ++++++ 16 files changed, 480 insertions(+), 11 deletions(-) create mode 100644 src/Modules/Catalog/Application/Commands/DeleteOfferCommand.cs create mode 100644 src/Modules/Catalog/Application/Commands/ToggleOfferStatusCommand.cs create mode 100644 src/Modules/Catalog/Application/Commands/UpdateOfferCommand.cs create mode 100644 src/Modules/Catalog/Application/Commands/UploadOfferImageCommand.cs create mode 100644 src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.Designer.cs create mode 100644 src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.cs diff --git a/src/Host/Endpoints/SearchEndpoints.cs b/src/Host/Endpoints/SearchEndpoints.cs index 1972457..4327727 100644 --- a/src/Host/Endpoints/SearchEndpoints.cs +++ b/src/Host/Endpoints/SearchEndpoints.cs @@ -43,7 +43,7 @@ public static class SearchEndpoints } var nearbyGeoRaw = await nearbyGeoQuery - .Select(x => new { x.Id, x.State, Latitude = x.Location.Coordinate.Y, Longitude = x.Location.Coordinate.X }) + .Select(x => new { x.Id, x.State, Latitude = x.Location.Coordinate.Y, Longitude = x.Location.Coordinate.X, x.LastUpdatedAt }) .ToListAsync(ct); var nearbyGeo = nearbyGeoRaw.Select(x => new @@ -52,6 +52,7 @@ public static class SearchEndpoints x.State, x.Latitude, x.Longitude, + x.LastUpdatedAt, DistanceMeters = CalculateDistanceInMeters(lat, lon, x.Latitude, x.Longitude) }).ToList(); @@ -60,7 +61,7 @@ public static class SearchEndpoints // 2. Ищем совпадения по услугам (CatalogDbContext) var offersQuery = catalogDb.Offers - .Where(o => nearbyIds.Contains(o.PerformerId) && o.IsActive); + .Where(o => nearbyIds.Contains(o.PerformerId) && o.IsActive && !o.IsDeleted); var qLower = q?.ToLower(); if (!string.IsNullOrWhiteSpace(qLower)) @@ -72,10 +73,10 @@ public static class SearchEndpoints } var matchedOffersRaw = await offersQuery - .Select(o => new { o.Id, o.PerformerId, o.Title, Description = o.Description ?? "", o.Price.Amount, o.Price.Currency }) + .Select(o => new { o.Id, o.PerformerId, o.Title, Description = o.Description ?? "", o.Price.Amount, o.Price.Currency, o.Images }) .ToListAsync(ct); - var matchedOffers = matchedOffersRaw.Select(o => new { o.Id, o.PerformerId, o.Title, o.Description, PriceAmount = o.Amount, PriceCurrency = "₽" }).ToList(); + var matchedOffers = matchedOffersRaw.Select(o => new { o.Id, o.PerformerId, o.Title, o.Description, PriceAmount = o.Amount, PriceCurrency = "₽", ImageUrl = o.Images.FirstOrDefault() }).ToList(); var performersWithOffers = matchedOffers.Select(o => o.PerformerId).Distinct().ToList(); @@ -119,6 +120,7 @@ public static class SearchEndpoints string finalStatus = CalculateSmartStatus( g.State.ToString(), // Передаем реальный статус из Geo + g.LastUpdatedAt, // Передаем время последнего обновления p.WorkSchedule?.IsAlwaysReady ?? false, p.WorkSchedule?.WorkingDays, currentDay, @@ -164,10 +166,15 @@ public static class SearchEndpoints .WithTags("Search"); } - private static string CalculateSmartStatus(string liveState, bool isAlwaysReady, string? workingDaysJson, string currentDay, TimeSpan currentTime) + private static string CalculateSmartStatus(string liveState, DateTime lastUpdatedAt, bool isAlwaysReady, string? workingDaysJson, string currentDay, TimeSpan currentTime) { if (liveState != "Available") return "Офлайн"; if (isAlwaysReady) return "Готов к заказу"; + + // Если пользователь вручную обновил статус 'Available' сегодня, он остается онлайн до конца дня, + // игнорируя расписание на сегодня. В следующий день снова начнет работать расписание. + if (lastUpdatedAt.Date == DateTime.UtcNow.Date) return "Готов к заказу"; + if (string.IsNullOrEmpty(workingDaysJson)) return "Готов к заказу"; // Если расписания нет - считаем готовым try diff --git a/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs index 7ed5b79..3f36a18 100644 --- a/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs +++ b/src/Modules/Catalog/Application/Commands/CreateOfferCommand.cs @@ -36,13 +36,16 @@ public record CreateOfferCommand : IRequest> /// public JsonDocument? Attributes { get; init; } - public CreateOfferCommand(Guid categoryId, string title, string description, Price price, JsonDocument? attributes) + public List? Images { get; init; } + + public CreateOfferCommand(Guid categoryId, string title, string description, Price price, JsonDocument? attributes, List? images) { CategoryId = categoryId; Title = title; Description = description; Price = price; Attributes = attributes; + Images = images; } public CreateOfferCommand() { } @@ -70,7 +73,8 @@ public class CreateOfferCommandHandler : IRequestHandler; + +public class DeleteOfferCommandHandler : IRequestHandler +{ + private readonly IOfferRepository _repository; + private readonly ICurrentUserService _currentUser; + + public DeleteOfferCommandHandler(IOfferRepository repository, ICurrentUserService currentUser) + { + _repository = repository; + _currentUser = currentUser; + } + + public async Task Handle(DeleteOfferCommand request, CancellationToken cancellationToken) + { + var offer = await _repository.GetByIdAsync(request.OfferId, cancellationToken); + if (offer == null) throw new Exception("Offer not found"); + + if (offer.PerformerId != _currentUser.UserId) + throw new UnauthorizedAccessException("Not your offer"); + + offer.Delete(); + await _repository.UpdateAsync(offer, cancellationToken); + return true; + } +} diff --git a/src/Modules/Catalog/Application/Commands/ToggleOfferStatusCommand.cs b/src/Modules/Catalog/Application/Commands/ToggleOfferStatusCommand.cs new file mode 100644 index 0000000..9b02cfa --- /dev/null +++ b/src/Modules/Catalog/Application/Commands/ToggleOfferStatusCommand.cs @@ -0,0 +1,32 @@ +using MediatR; +using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.Modules.Catalog.Domain.Repositories; + +namespace Nashel.Modules.Catalog.Application.Commands; + +public record ToggleOfferStatusCommand(Guid OfferId) : IRequest; + +public class ToggleOfferStatusCommandHandler : IRequestHandler +{ + private readonly IOfferRepository _repository; + private readonly ICurrentUserService _currentUser; + + public ToggleOfferStatusCommandHandler(IOfferRepository repository, ICurrentUserService currentUser) + { + _repository = repository; + _currentUser = currentUser; + } + + public async Task Handle(ToggleOfferStatusCommand request, CancellationToken cancellationToken) + { + var offer = await _repository.GetByIdAsync(request.OfferId, cancellationToken); + if (offer == null) throw new Exception("Offer not found"); + + if (offer.PerformerId != _currentUser.UserId) + throw new UnauthorizedAccessException("Not your offer"); + + offer.ToggleActive(); + await _repository.UpdateAsync(offer, cancellationToken); + return offer.IsActive; + } +} diff --git a/src/Modules/Catalog/Application/Commands/UpdateOfferCommand.cs b/src/Modules/Catalog/Application/Commands/UpdateOfferCommand.cs new file mode 100644 index 0000000..f3385d0 --- /dev/null +++ b/src/Modules/Catalog/Application/Commands/UpdateOfferCommand.cs @@ -0,0 +1,48 @@ +using System.Text.Json; +using MediatR; +using Nashel.BuildingBlocks.Application.Abstractions; +using Nashel.Modules.Catalog.Domain.Aggregates; +using Nashel.Modules.Catalog.Domain.Repositories; +using Nashel.Modules.Catalog.Domain.ValueObjects; + +namespace Nashel.Modules.Catalog.Application.Commands; + +public record UpdateOfferCommand( + Guid OfferId, + string Title, + string Description, + decimal PriceAmount, + int PriceType, // 0-Fixed, 1-Hourly, 2-Negotiable + Dictionary? Attributes, + List? Images) : IRequest; + +public class UpdateOfferCommandHandler : IRequestHandler +{ + private readonly IOfferRepository _repository; + private readonly ICurrentUserService _currentUser; + + public UpdateOfferCommandHandler(IOfferRepository repository, ICurrentUserService currentUser) + { + _repository = repository; + _currentUser = currentUser; + } + + public async Task Handle(UpdateOfferCommand request, CancellationToken cancellationToken) + { + var offer = await _repository.GetByIdAsync(request.OfferId, cancellationToken); + if (offer == null) throw new Exception("Offer not found"); + + if (offer.PerformerId != _currentUser.UserId) + throw new UnauthorizedAccessException("Not your offer"); + + var price = new Price(request.PriceAmount, (Nashel.Modules.Catalog.Domain.Enums.OfferType)request.PriceType); + var jsonAttrs = request.Attributes != null && request.Attributes.Count > 0 + ? JsonDocument.Parse(JsonSerializer.Serialize(request.Attributes)) + : null; + + offer.Update(request.Title, request.Description, price, jsonAttrs, request.Images); + + await _repository.UpdateAsync(offer, cancellationToken); + return true; + } +} diff --git a/src/Modules/Catalog/Application/Commands/UploadOfferImageCommand.cs b/src/Modules/Catalog/Application/Commands/UploadOfferImageCommand.cs new file mode 100644 index 0000000..4812b63 --- /dev/null +++ b/src/Modules/Catalog/Application/Commands/UploadOfferImageCommand.cs @@ -0,0 +1,43 @@ +using MediatR; +using Nashel.BuildingBlocks.Application.Abstractions; + +namespace Nashel.Modules.Catalog.Application.Commands; + +public record UploadOfferImageCommand(byte[] Content, string FileName) : IRequest; + +public class UploadOfferImageCommandHandler : IRequestHandler +{ + private const long MaxFileSize = 5 * 1024 * 1024; // 5MB + private static readonly string[] AllowedExtensions = { ".jpg", ".jpeg", ".png", ".webp" }; + + private readonly ICurrentUserService _currentUserService; + + public UploadOfferImageCommandHandler(ICurrentUserService currentUserService) + { + _currentUserService = currentUserService; + } + + public Task Handle(UploadOfferImageCommand request, CancellationToken cancellationToken) + { + var userId = _currentUserService.UserId; + if (userId == null) throw new UnauthorizedAccessException(); + + if (request.Content.Length > MaxFileSize) + throw new InvalidOperationException("Размер файла не должен превышать 5 МБ"); + + var extension = Path.GetExtension(request.FileName).ToLowerInvariant(); + if (Array.IndexOf(AllowedExtensions, extension) == -1) + throw new InvalidOperationException("Допустимые форматы: JPG, PNG, WEBP"); + + var base64String = Convert.ToBase64String(request.Content); + + var mimeType = extension switch + { + ".png" => "image/png", + ".webp" => "image/webp", + _ => "image/jpeg" + }; + + return Task.FromResult($"data:{mimeType};base64,{base64String}"); + } +} diff --git a/src/Modules/Catalog/Application/Common/Dtos.cs b/src/Modules/Catalog/Application/Common/Dtos.cs index d68f57f..6f27b60 100644 --- a/src/Modules/Catalog/Application/Common/Dtos.cs +++ b/src/Modules/Catalog/Application/Common/Dtos.cs @@ -90,7 +90,12 @@ public record OfferDto /// public bool IsActive { get; init; } - public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive) + /// + /// Изображения услуги. + /// + public List Images { get; init; } = new(); + + public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive, List? images = null) { Id = id; PerformerId = performerId; @@ -100,6 +105,10 @@ public record OfferDto Price = price; Attributes = attributes; IsActive = isActive; + if (images != null) + { + Images.AddRange(images); + } } public OfferDto() { } diff --git a/src/Modules/Catalog/Application/Queries/GetMyOffersQuery.cs b/src/Modules/Catalog/Application/Queries/GetMyOffersQuery.cs index cdbf504..2d3215e 100644 --- a/src/Modules/Catalog/Application/Queries/GetMyOffersQuery.cs +++ b/src/Modules/Catalog/Application/Queries/GetMyOffersQuery.cs @@ -37,7 +37,8 @@ public class GetMyOffersQueryHandler : IRequestHandler>.Success(list); diff --git a/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs index 5482052..407ee4d 100644 --- a/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs +++ b/src/Modules/Catalog/Application/Queries/GetOfferByIdQuery.cs @@ -32,7 +32,8 @@ public class GetOfferByIdQueryHandler : IRequestHandler.Success(dto); diff --git a/src/Modules/Catalog/Domain/Aggregates/Offer.cs b/src/Modules/Catalog/Domain/Aggregates/Offer.cs index 270625c..d39cfcc 100644 --- a/src/Modules/Catalog/Domain/Aggregates/Offer.cs +++ b/src/Modules/Catalog/Domain/Aggregates/Offer.cs @@ -48,18 +48,28 @@ public class Offer /// public bool IsActive { get; private set; } + /// + /// Удалено ли объявление (Soft Delete). + /// + public bool IsDeleted { get; private set; } + /// /// Дата создания. /// public DateTimeOffset CreatedAt { get; private set; } + /// + /// Коллекция изображений (Base64 URL) + /// + public List Images { get; private set; } = new(); + // Конструктор по умолчанию для EF Core private Offer() { } /// /// Создает новый оффер. /// - public Offer(Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes = null) + public Offer(Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes = null, List? images = null) { Id = Guid.NewGuid(); PerformerId = performerId; @@ -69,6 +79,36 @@ public class Offer Price = price; Attributes = attributes; IsActive = true; + IsDeleted = false; CreatedAt = DateTimeOffset.UtcNow; + if (images != null) + { + Images.AddRange(images); + } + } + + public void Update(string title, string description, Price price, JsonDocument? attributes, List? images) + { + Title = title; + Description = description; + Price = price; + Attributes = attributes; + + Images.Clear(); + if (images != null) + { + Images.AddRange(images); + } + } + + public void ToggleActive() + { + IsActive = !IsActive; + } + + public void Delete() + { + IsDeleted = true; + IsActive = false; } } diff --git a/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs b/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs index 3a9da4e..41042ad 100644 --- a/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs +++ b/src/Modules/Catalog/Domain/Repositories/IOfferRepository.cs @@ -7,4 +7,5 @@ public interface IOfferRepository Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); Task> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default); Task AddAsync(Offer offer, CancellationToken cancellationToken = default); + Task UpdateAsync(Offer offer, CancellationToken cancellationToken = default); } diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.Designer.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.Designer.cs new file mode 100644 index 0000000..acb30f1 --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.Designer.cs @@ -0,0 +1,148 @@ +// +using System; +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Nashel.Modules.Catalog.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(CatalogDbContext))] + [Migration("20260306185654_AddOfferImagesAndSoftDelete")] + partial class AddOfferImagesAndSoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeSchema") + .HasColumnType("jsonb"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Categories", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attributes") + .HasColumnType("jsonb"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.PrimitiveCollection>("Images") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PerformerId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Attributes"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Attributes"), "gin"); + + b.ToTable("Offers", "catalog"); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Category", b => + { + b.HasOne("Nashel.Modules.Catalog.Domain.Aggregates.Category", null) + .WithMany() + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("Nashel.Modules.Catalog.Domain.Aggregates.Offer", b => + { + b.OwnsOne("Nashel.Modules.Catalog.Domain.ValueObjects.Price", "Price", b1 => + { + b1.Property("OfferId") + .HasColumnType("uuid"); + + b1.Property("Amount") + .HasColumnType("numeric") + .HasColumnName("PriceAmount"); + + b1.Property("Currency") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasColumnName("PriceCurrency"); + + b1.Property("Type") + .HasColumnType("integer") + .HasColumnName("PriceType"); + + b1.HasKey("OfferId"); + + b1.ToTable("Offers", "catalog"); + + b1.WithOwner() + .HasForeignKey("OfferId"); + }); + + b.Navigation("Price") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.cs new file mode 100644 index 0000000..5e92d5c --- /dev/null +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/20260306185654_AddOfferImagesAndSoftDelete.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddOfferImagesAndSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn>( + name: "Images", + schema: "catalog", + table: "Offers", + type: "text[]", + nullable: false, + defaultValue: new string[0]); + + migrationBuilder.AddColumn( + name: "IsDeleted", + schema: "catalog", + table: "Offers", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Images", + schema: "catalog", + table: "Offers"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + schema: "catalog", + table: "Offers"); + } + } +} diff --git a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs index 1a10d82..6d5d399 100644 --- a/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs +++ b/src/Modules/Catalog/Infrastructure/Persistence/Migrations/CatalogDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -72,9 +73,16 @@ namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations b.Property("Description") .HasColumnType("text"); + b.PrimitiveCollection>("Images") + .IsRequired() + .HasColumnType("text[]"); + b.Property("IsActive") .HasColumnType("boolean"); + b.Property("IsDeleted") + .HasColumnType("boolean"); + b.Property("PerformerId") .HasColumnType("uuid"); diff --git a/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs b/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs index 20aa42e..7b91749 100644 --- a/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs +++ b/src/Modules/Catalog/Infrastructure/Repositories/OfferRepository.cs @@ -32,4 +32,10 @@ public class OfferRepository : IOfferRepository await _context.Offers.AddAsync(offer, cancellationToken); await _context.SaveChangesAsync(cancellationToken); } + + public async Task UpdateAsync(Offer offer, CancellationToken cancellationToken = default) + { + _context.Offers.Update(offer); + await _context.SaveChangesAsync(cancellationToken); + } } diff --git a/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs b/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs index 6996275..d516004 100644 --- a/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs +++ b/src/Modules/Catalog/Presentation/Endpoints/CatalogEndpoints.cs @@ -62,5 +62,49 @@ public static class CatalogEndpoints }) .WithName("GetOfferById") .WithOpenApi(operation => new(operation) { Summary = "Получить детали услуги по ID", Description = "Возвращает полную информацию об услуге." }); + + protectedOffersGroup.MapPut("/offers/{id:guid}", async (Guid id, [FromBody] UpdateOfferPayload payload, ISender sender) => + { + var command = new UpdateOfferCommand(id, payload.Title, payload.Description, payload.Price.Amount, payload.Price.Type, payload.Attributes, payload.Images); + await sender.Send(command); + return Results.NoContent(); + }) + .WithName("UpdateOffer") + .WithOpenApi(operation => new(operation) { Summary = "Обновить услугу" }); + + protectedOffersGroup.MapPatch("/offers/{id:guid}/toggle", async (Guid id, ISender sender) => + { + var isActive = await sender.Send(new ToggleOfferStatusCommand(id)); + return Results.Ok(new { IsActive = isActive }); + }) + .WithName("ToggleOfferStatus") + .WithOpenApi(operation => new(operation) { Summary = "Приостановить/активировать услугу" }); + + protectedOffersGroup.MapDelete("/offers/{id:guid}", async (Guid id, ISender sender) => + { + await sender.Send(new DeleteOfferCommand(id)); + return Results.NoContent(); + }) + .WithName("DeleteOffer") + .WithOpenApi(operation => new(operation) { Summary = "Удалить услугу" }); + + protectedOffersGroup.MapPost("/offers/image", async (IFormFile file, ISender sender) => + { + using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + var url = await sender.Send(new UploadOfferImageCommand(ms.ToArray(), file.FileName)); + return Results.Ok(new { url }); + }) + .WithName("UploadOfferImage") + .WithOpenApi(operation => new(operation) { Summary = "Загрузить изображение для услуги" }) + .DisableAntiforgery(); } } + +public record UpdateOfferPayload( + string Title, + string Description, + PricePayload Price, + Dictionary? Attributes, + List? Images); +public record PricePayload(decimal Amount, int Type);