Изображения в услугах, поиске, возможность открыть карточку услуги

This commit is contained in:
Халимов Рустам
2026-03-06 23:14:39 +03:00
parent 420a86b92f
commit f5e20c1fb2
16 changed files with 480 additions and 11 deletions

View File

@@ -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

View File

@@ -36,13 +36,16 @@ public record CreateOfferCommand : IRequest<Result<Guid>>
/// </summary>
public JsonDocument? Attributes { get; init; }
public CreateOfferCommand(Guid categoryId, string title, string description, Price price, JsonDocument? attributes)
public List<string>? Images { get; init; }
public CreateOfferCommand(Guid categoryId, string title, string description, Price price, JsonDocument? attributes, List<string>? images)
{
CategoryId = categoryId;
Title = title;
Description = description;
Price = price;
Attributes = attributes;
Images = images;
}
public CreateOfferCommand() { }
@@ -70,7 +73,8 @@ public class CreateOfferCommandHandler : IRequestHandler<CreateOfferCommand, Res
request.Title,
request.Description,
request.Price,
request.Attributes
request.Attributes,
request.Images
);
await _repository.AddAsync(offer, cancellationToken);

View File

@@ -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 DeleteOfferCommand(Guid OfferId) : IRequest<bool>;
public class DeleteOfferCommandHandler : IRequestHandler<DeleteOfferCommand, bool>
{
private readonly IOfferRepository _repository;
private readonly ICurrentUserService _currentUser;
public DeleteOfferCommandHandler(IOfferRepository repository, ICurrentUserService currentUser)
{
_repository = repository;
_currentUser = currentUser;
}
public async Task<bool> 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;
}
}

View File

@@ -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<bool>;
public class ToggleOfferStatusCommandHandler : IRequestHandler<ToggleOfferStatusCommand, bool>
{
private readonly IOfferRepository _repository;
private readonly ICurrentUserService _currentUser;
public ToggleOfferStatusCommandHandler(IOfferRepository repository, ICurrentUserService currentUser)
{
_repository = repository;
_currentUser = currentUser;
}
public async Task<bool> 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;
}
}

View File

@@ -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<string, string>? Attributes,
List<string>? Images) : IRequest<bool>;
public class UpdateOfferCommandHandler : IRequestHandler<UpdateOfferCommand, bool>
{
private readonly IOfferRepository _repository;
private readonly ICurrentUserService _currentUser;
public UpdateOfferCommandHandler(IOfferRepository repository, ICurrentUserService currentUser)
{
_repository = repository;
_currentUser = currentUser;
}
public async Task<bool> 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;
}
}

View File

@@ -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<string>;
public class UploadOfferImageCommandHandler : IRequestHandler<UploadOfferImageCommand, string>
{
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<string> 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}");
}
}

View File

@@ -90,7 +90,12 @@ public record OfferDto
/// </summary>
public bool IsActive { get; init; }
public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive)
/// <summary>
/// Изображения услуги.
/// </summary>
public List<string> Images { get; init; } = new();
public OfferDto(Guid id, Guid performerId, Guid categoryId, string title, string description, Price price, JsonDocument? attributes, bool isActive, List<string>? 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() { }

View File

@@ -37,7 +37,8 @@ public class GetMyOffersQueryHandler : IRequestHandler<GetMyOffersQuery, Result<
offer.Description ?? string.Empty,
offer.Price,
offer.Attributes,
offer.IsActive
offer.IsActive,
offer.Images
)).ToList();
return Result<List<OfferDto>>.Success(list);

View File

@@ -32,7 +32,8 @@ public class GetOfferByIdQueryHandler : IRequestHandler<GetOfferByIdQuery, Resul
offer.Description ?? string.Empty,
offer.Price,
offer.Attributes,
offer.IsActive
offer.IsActive,
offer.Images
);
return Result<OfferDto>.Success(dto);

View File

@@ -48,18 +48,28 @@ public class Offer
/// </summary>
public bool IsActive { get; private set; }
/// <summary>
/// Удалено ли объявление (Soft Delete).
/// </summary>
public bool IsDeleted { get; private set; }
/// <summary>
/// Дата создания.
/// </summary>
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>
/// Коллекция изображений (Base64 URL)
/// </summary>
public List<string> Images { get; private set; } = new();
// Конструктор по умолчанию для EF Core
private Offer() { }
/// <summary>
/// Создает новый оффер.
/// </summary>
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<string>? 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<string>? 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;
}
}

View File

@@ -7,4 +7,5 @@ public interface IOfferRepository
Task<Offer?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<List<Offer>> GetByPerformerIdAsync(Guid performerId, CancellationToken cancellationToken = default);
Task AddAsync(Offer offer, CancellationToken cancellationToken = default);
Task UpdateAsync(Offer offer, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,148 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<JsonDocument>("AttributeSchema")
.HasColumnType("jsonb");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<JsonDocument>("Attributes")
.HasColumnType("jsonb");
b.Property<Guid>("CategoryId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Images")
.IsRequired()
.HasColumnType("text[]");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");
b.Property<string>("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<Guid>("OfferId")
.HasColumnType("uuid");
b1.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("PriceAmount");
b1.Property<string>("Currency")
.IsRequired()
.HasMaxLength(3)
.HasColumnType("character varying(3)")
.HasColumnName("PriceCurrency");
b1.Property<int>("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
}
}
}

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nashel.Modules.Catalog.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddOfferImagesAndSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<List<string>>(
name: "Images",
schema: "catalog",
table: "Offers",
type: "text[]",
nullable: false,
defaultValue: new string[0]);
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
schema: "catalog",
table: "Offers",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Images",
schema: "catalog",
table: "Offers");
migrationBuilder.DropColumn(
name: "IsDeleted",
schema: "catalog",
table: "Offers");
}
}
}

View File

@@ -1,5 +1,6 @@
// <auto-generated />
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<string>("Description")
.HasColumnType("text");
b.PrimitiveCollection<List<string>>("Images")
.IsRequired()
.HasColumnType("text[]");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<Guid>("PerformerId")
.HasColumnType("uuid");

View File

@@ -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);
}
}

View File

@@ -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<string, string>? Attributes,
List<string>? Images);
public record PricePayload(decimal Amount, int Type);