Files
soroush.asadi 67060c73b2 feat(admin): discount edit/delete + project-scoped scene/color editor
Identity (discounts):
- DiscountsController: PUT /v1/discounts/{id}, DELETE /v1/discounts/{id}
- DiscountService.UpdateAsync (partial update, code-clash guard) + DeleteAsync
- UpdateDiscountRequest record (all fields optional incl. is_active)
- Frontend discountsConfig: canEdit + canDelete + is_active field

Content (scenes/colors — UI for existing CRUD endpoints):
- New SceneColorEditor.tsx: 3-tab modal (scenes / shared-colors / color-presets),
  project-scoped, full add/edit/delete per tab, colour pickers + palette item editor
- Wired into TemplatesAdmin: "صحنه‌ها و رنگ‌ها" button per template variant row
- Routes through the generic admin proxy with ?project_id=

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 12:16:13 +03:30

139 lines
5.8 KiB
C#

using FlatRender.IdentitySvc.Application.Services.Interfaces;
using FlatRender.IdentitySvc.Domain.Entities;
using FlatRender.IdentitySvc.Domain.Enums;
using FlatRender.IdentitySvc.Infrastructure.Data;
using FlatRender.IdentitySvc.Models.Requests;
using FlatRender.IdentitySvc.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.IdentitySvc.Application.Services;
public class DiscountService(IdentityDbContext db) : IDiscountService
{
public async Task<DiscountValidateResponse> ValidateAsync(Guid tenantId, string code, Guid? planId)
{
var discount = await db.Discounts.FirstOrDefaultAsync(d =>
d.TenantId == tenantId && d.Code == code && d.IsActive &&
(d.StartsAt == null || d.StartsAt <= DateTime.UtcNow) &&
(d.ExpiresAt == null || d.ExpiresAt >= DateTime.UtcNow) &&
(d.MaxUseCount == null || d.UsedCount < d.MaxUseCount));
if (discount == null)
return new DiscountValidateResponse(false, 0, "Unknown", 0);
if (planId.HasValue && discount.AppliesToPlanIds != null && discount.AppliesToPlanIds.Length > 0)
{
if (!discount.AppliesToPlanIds.Contains(planId.Value))
return new DiscountValidateResponse(false, 0, discount.Kind.ToString(), discount.Value);
}
long discountMinor = 0;
if (planId.HasValue)
{
var plan = await db.Plans.FindAsync(planId.Value);
if (plan != null)
{
discountMinor = discount.Kind == DiscountKind.Percentage
? (long)(plan.PriceMinor * (double)discount.Value / 100)
: (long)discount.Value;
}
}
return new DiscountValidateResponse(true, discountMinor, discount.Kind.ToString(), discount.Value);
}
public async Task<PagedResponse<DiscountResponse>> ListAsync(Guid tenantId, int page, int pageSize)
{
var total = await db.Discounts.LongCountAsync(d => d.TenantId == tenantId);
var discounts = await db.Discounts
.Where(d => d.TenantId == tenantId)
.OrderByDescending(d => d.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResponse<DiscountResponse>(
discounts.Select(MapResponse).ToList(),
new PaginationMeta(page, pageSize, total, total > (long)page * pageSize)
);
}
public async Task<DiscountResponse> CreateAsync(Guid tenantId, CreateDiscountRequest request)
{
var exists = await db.Discounts.AnyAsync(d => d.TenantId == tenantId && d.Code == request.Code);
if (exists) throw new InvalidOperationException("Discount code already exists");
if (!Enum.TryParse<DiscountKind>(request.Kind, true, out var kind))
throw new ArgumentException("Invalid discount kind");
var discount = new Discount
{
TenantId = tenantId,
Name = request.Name,
Code = request.Code.ToUpper(),
Kind = kind,
Value = request.Value,
OwnerUserId = request.OwnerUserId,
OwnerProfitPercentage = request.OwnerProfitPercentage,
MaxUseCount = request.MaxUseCount,
AppliesToPlanIds = request.AppliesToPlanIds,
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
};
db.Discounts.Add(discount);
await db.SaveChangesAsync();
return MapResponse(discount);
}
public async Task<DiscountResponse?> UpdateAsync(Guid tenantId, Guid id, UpdateDiscountRequest request)
{
var discount = await db.Discounts.FirstOrDefaultAsync(d => d.Id == id && d.TenantId == tenantId);
if (discount == null) return null;
if (request.Name != null) discount.Name = request.Name;
if (request.Code != null)
{
var newCode = request.Code.ToUpper();
if (newCode != discount.Code)
{
var clash = await db.Discounts.AnyAsync(d =>
d.TenantId == tenantId && d.Code == newCode && d.Id != id);
if (clash) throw new InvalidOperationException("Discount code already exists");
discount.Code = newCode;
}
}
if (request.Kind != null)
{
if (!Enum.TryParse<DiscountKind>(request.Kind, true, out var kind))
throw new ArgumentException("Invalid discount kind");
discount.Kind = kind;
}
if (request.Value.HasValue) discount.Value = request.Value.Value;
if (request.OwnerUserId.HasValue) discount.OwnerUserId = request.OwnerUserId;
if (request.OwnerProfitPercentage.HasValue) discount.OwnerProfitPercentage = request.OwnerProfitPercentage.Value;
if (request.MaxUseCount.HasValue) discount.MaxUseCount = request.MaxUseCount;
if (request.AppliesToPlanIds != null) discount.AppliesToPlanIds = request.AppliesToPlanIds;
if (request.StartsAt.HasValue) discount.StartsAt = request.StartsAt;
if (request.ExpiresAt.HasValue) discount.ExpiresAt = request.ExpiresAt;
if (request.IsActive.HasValue) discount.IsActive = request.IsActive.Value;
discount.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return MapResponse(discount);
}
public async Task<bool> DeleteAsync(Guid tenantId, Guid id)
{
var discount = await db.Discounts.FirstOrDefaultAsync(d => d.Id == id && d.TenantId == tenantId);
if (discount == null) return false;
db.Discounts.Remove(discount);
await db.SaveChangesAsync();
return true;
}
private static DiscountResponse MapResponse(Discount d) => new(
d.Id, d.Name, d.Code, d.Kind.ToString(), d.Value,
d.UsedCount, d.MaxUseCount, d.IsActive, d.ExpiresAt, d.CreatedAt
);
}