| | | 1 | | using System.Globalization; |
| | | 2 | | using System.Net.Mail; |
| | | 3 | | using System.Text.Json; |
| | | 4 | | using System.Text.RegularExpressions; |
| | | 5 | | using Microsoft.EntityFrameworkCore; |
| | | 6 | | using OzonDomains.Models.Partners; |
| | | 7 | | using OzonRepositories.Context; |
| | | 8 | | |
| | | 9 | | namespace Services.PartnerServices; |
| | | 10 | | |
| | | 11 | | public interface IPartnerManagementService |
| | | 12 | | { |
| | | 13 | | Task<IReadOnlyList<PartnerListItemDto>> ListAsync(CancellationToken ct = default); |
| | | 14 | | |
| | | 15 | | Task<PartnerDetailsDto?> GetAsync(long id, CancellationToken ct = default); |
| | | 16 | | |
| | | 17 | | Task<PartnerOpResult<PartnerDetailsDto>> CreateAsync(CreatePartnerRequest request, string ownerId, CancellationToken |
| | | 18 | | |
| | | 19 | | Task<PartnerOpResult<PartnerDetailsDto>> UpdateAsync(long id, UpdatePartnerRequest request, string ownerId, Cancella |
| | | 20 | | |
| | | 21 | | Task<PartnerOpResult<PartnerDetailsDto>> SetPermissionsAsync(long id, IReadOnlyCollection<PartnerPermissionCode> per |
| | | 22 | | |
| | | 23 | | /// <summary>Полностью заменяет набор правил партнёра.</summary> |
| | | 24 | | Task<PartnerOpResult<PartnerDetailsDto>> SaveRulesAsync(long id, IReadOnlyList<PartnerRuleDto> rules, string ownerId |
| | | 25 | | |
| | | 26 | | Task<PartnerOpResult<bool>> DeleteAsync(long id, string ownerId, CancellationToken ct = default); |
| | | 27 | | } |
| | | 28 | | |
| | 43 | 29 | | public sealed partial class PartnerManagementService( |
| | 43 | 30 | | ApplicationDbContext db, |
| | 43 | 31 | | IPartnerAuditService audit) : IPartnerManagementService |
| | | 32 | | { |
| | | 33 | | internal const int MaxRulesPerPartner = 50; |
| | | 34 | | internal const int MaxValuesPerCondition = 500; |
| | | 35 | | |
| | | 36 | | |
| | | 37 | | public async Task<IReadOnlyList<PartnerListItemDto>> ListAsync(CancellationToken ct = default) |
| | | 38 | | { |
| | 0 | 39 | | var partners = await db.Partners.AsNoTracking() |
| | 0 | 40 | | .Include(x => x.Permissions) |
| | 0 | 41 | | .OrderBy(x => x.Name) |
| | 0 | 42 | | .ToListAsync(ct); |
| | 0 | 43 | | var ruleCounts = await db.PartnerRules.AsNoTracking() |
| | 0 | 44 | | .GroupBy(x => x.PartnerId) |
| | 0 | 45 | | .Select(g => new { PartnerId = g.Key, Count = g.Count() }) |
| | | 46 | | .ToDictionaryAsync(x => x.PartnerId, x => x.Count, ct); |
| | | 47 | | |
| | 0 | 48 | | return partners |
| | 0 | 49 | | .Select(p => new PartnerListItemDto( |
| | 0 | 50 | | p.Id, p.Name, p.Login, p.Email, p.GoogleEmail, p.IsActive, p.LastLoginAtUtc, |
| | 0 | 51 | | ruleCounts.GetValueOrDefault(p.Id), |
| | | 52 | | p.Permissions.Select(x => x.Code).OrderBy(x => x).ToList())) |
| | 0 | 53 | | .ToList(); |
| | 0 | 54 | | } |
| | | 55 | | |
| | | 56 | | public async Task<PartnerDetailsDto?> GetAsync(long id, CancellationToken ct = default) |
| | | 57 | | { |
| | 0 | 58 | | var partner = await LoadAsync(id, tracking: false, ct); |
| | 0 | 59 | | return partner is null ? null : ToDetails(partner); |
| | 0 | 60 | | } |
| | | 61 | | |
| | | 62 | | public async Task<PartnerOpResult<PartnerDetailsDto>> CreateAsync( |
| | | 63 | | CreatePartnerRequest request, string ownerId, CancellationToken ct = default) |
| | | 64 | | { |
| | 44 | 65 | | var name = (request.Name ?? string.Empty).Trim(); |
| | 44 | 66 | | if (name.Length is 0 or > 200) |
| | 0 | 67 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Название обязательно, до 200 символов."); |
| | | 68 | | |
| | 44 | 69 | | var email = PartnerEmail.Normalize(request.Email); |
| | 44 | 70 | | if (email.Length == 0) |
| | 2 | 71 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Укажите почту партнёра: на неё придёт приглашение."); |
| | 42 | 72 | | if (!PartnerEmail.IsValid(email)) |
| | 5 | 73 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Некорректная почта партнёра."); |
| | | 74 | | |
| | 37 | 75 | | var (googleEmail, googleError) = NormalizeGoogleEmail(request.GoogleEmail); |
| | 37 | 76 | | if (googleError is not null) |
| | 0 | 77 | | return PartnerOpResult<PartnerDetailsDto>.Fail(googleError); |
| | | 78 | | |
| | 37 | 79 | | if (await db.Partners.AnyAsync(x => x.Email == email, ct)) |
| | 1 | 80 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр с такой почтой уже есть."); |
| | 36 | 81 | | if (googleEmail is not null && await db.Partners.AnyAsync(x => x.GoogleEmail == googleEmail, ct)) |
| | 1 | 82 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Эта Google-почта уже привязана к другому партнёру."); |
| | | 83 | | |
| | | 84 | | // Логин больше не нужен для входа, но остаётся уникальным идентификатором для журнала. |
| | 35 | 85 | | var login = email.Length <= 100 && !await db.Partners.AnyAsync(x => x.Login == email, ct) |
| | 35 | 86 | | ? email |
| | 35 | 87 | | : $"partner-{Guid.NewGuid():N}"; |
| | 35 | 88 | | var partner = new Partner { Name = name, Login = login, Email = email, GoogleEmail = googleEmail, PasswordHash = |
| | 35 | 89 | | db.Partners.Add(partner); |
| | 35 | 90 | | await db.SaveChangesAsync(ct); |
| | | 91 | | |
| | 35 | 92 | | await audit.WriteAsync("partner_created", PartnerActorKind.Owner, ownerId, partner.Id, |
| | 35 | 93 | | details: $"Почта: {partner.Email}", ct: ct); |
| | 35 | 94 | | return PartnerOpResult<PartnerDetailsDto>.Success(ToDetails(partner)); |
| | 44 | 95 | | } |
| | | 96 | | |
| | | 97 | | public async Task<PartnerOpResult<PartnerDetailsDto>> UpdateAsync( |
| | | 98 | | long id, UpdatePartnerRequest request, string ownerId, CancellationToken ct = default) |
| | | 99 | | { |
| | 8 | 100 | | var partner = await LoadAsync(id, tracking: true, ct); |
| | 8 | 101 | | if (partner is null) return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр не найден."); |
| | | 102 | | |
| | 8 | 103 | | var name = (request.Name ?? string.Empty).Trim(); |
| | 8 | 104 | | if (name.Length is 0 or > 200) |
| | 0 | 105 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Название обязательно, до 200 символов."); |
| | | 106 | | |
| | 8 | 107 | | var email = PartnerEmail.Normalize(request.Email); |
| | 8 | 108 | | if (email.Length == 0) |
| | | 109 | | { |
| | | 110 | | // Почту нельзя убрать у партнёра, у которого она уже есть: иначе он потеряет вход без предупреждения. |
| | 2 | 111 | | if (partner.Email is not null) |
| | 1 | 112 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Почту нельзя очистить: на неё приходят коды входа."); |
| | | 113 | | } |
| | | 114 | | else |
| | | 115 | | { |
| | 6 | 116 | | if (!PartnerEmail.IsValid(email)) |
| | 0 | 117 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Некорректная почта партнёра."); |
| | 6 | 118 | | if (email != partner.Email && await db.Partners.AnyAsync(x => x.Email == email && x.Id != id, ct)) |
| | 1 | 119 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр с такой почтой уже есть."); |
| | | 120 | | } |
| | | 121 | | |
| | 6 | 122 | | var (googleEmail, googleError) = NormalizeGoogleEmail(request.GoogleEmail); |
| | 6 | 123 | | if (googleError is not null) |
| | 0 | 124 | | return PartnerOpResult<PartnerDetailsDto>.Fail(googleError); |
| | 6 | 125 | | if (googleEmail is not null && await db.Partners.AnyAsync(x => x.GoogleEmail == googleEmail && x.Id != id, ct)) |
| | 0 | 126 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Эта Google-почта уже привязана к другому партнёру."); |
| | | 127 | | |
| | 6 | 128 | | var notificationEmail = string.IsNullOrWhiteSpace(request.NotificationEmail) |
| | 6 | 129 | | ? null |
| | 6 | 130 | | : request.NotificationEmail.Trim(); |
| | 6 | 131 | | if (notificationEmail is not null && !IsValidEmail(notificationEmail)) |
| | 0 | 132 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Некорректный email для уведомлений."); |
| | 6 | 133 | | if (request.NotificationChannels.HasFlag(PartnerNotificationChannels.Email) && notificationEmail is null) |
| | 1 | 134 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Для канала Email укажите адрес уведомлений."); |
| | | 135 | | |
| | 5 | 136 | | var telegramChatId = string.IsNullOrWhiteSpace(request.TelegramChatId) ? null : request.TelegramChatId.Trim(); |
| | 5 | 137 | | if (request.NotificationChannels.HasFlag(PartnerNotificationChannels.Telegram) && telegramChatId is null) |
| | 0 | 138 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Для канала Telegram укажите chat id."); |
| | | 139 | | |
| | 5 | 140 | | var wasActive = partner.IsActive; |
| | 5 | 141 | | var oldEmail = partner.Email; |
| | 5 | 142 | | var emailChanged = email.Length > 0 && email != oldEmail; |
| | 5 | 143 | | partner.Name = name; |
| | 6 | 144 | | if (emailChanged) partner.Email = email; |
| | 5 | 145 | | partner.GoogleEmail = googleEmail; |
| | 5 | 146 | | partner.IsActive = request.IsActive; |
| | 5 | 147 | | partner.NotificationChannels = request.NotificationChannels; |
| | 5 | 148 | | partner.NotificationEmail = notificationEmail; |
| | 5 | 149 | | partner.TelegramChatId = telegramChatId; |
| | | 150 | | // Блокировка и смена почты обрывают действующие сессии и делают недействительными выданные коды. |
| | 9 | 151 | | if ((wasActive && !request.IsActive) || emailChanged) RotateSecurityStamp(partner); |
| | 5 | 152 | | if (emailChanged) |
| | | 153 | | { |
| | 1 | 154 | | var open = await db.PartnerLoginCodes |
| | 1 | 155 | | .Where(x => x.PartnerId == id && x.ConsumedAtUtc == null) |
| | 1 | 156 | | .ToListAsync(ct); |
| | 5 | 157 | | foreach (var code in open) code.ConsumedAtUtc = DateTime.UtcNow; |
| | | 158 | | } |
| | | 159 | | |
| | 5 | 160 | | await db.SaveChangesAsync(ct); |
| | 5 | 161 | | if (emailChanged) |
| | 1 | 162 | | await audit.WriteAsync("partner_email_changed", PartnerActorKind.Owner, ownerId, partner.Id, |
| | 1 | 163 | | details: $"{oldEmail ?? "(не было)"} → {partner.Email}", ct: ct); |
| | 5 | 164 | | await audit.WriteAsync(wasActive == request.IsActive ? "partner_updated" : request.IsActive ? "partner_enabled" |
| | 5 | 165 | | PartnerActorKind.Owner, ownerId, partner.Id, ct: ct); |
| | 5 | 166 | | return PartnerOpResult<PartnerDetailsDto>.Success(ToDetails(partner)); |
| | 8 | 167 | | } |
| | | 168 | | |
| | | 169 | | public async Task<PartnerOpResult<PartnerDetailsDto>> SetPermissionsAsync( |
| | | 170 | | long id, IReadOnlyCollection<PartnerPermissionCode> permissions, string ownerId, CancellationToken ct = default) |
| | | 171 | | { |
| | 3 | 172 | | var partner = await LoadAsync(id, tracking: true, ct); |
| | 3 | 173 | | if (partner is null) return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр не найден."); |
| | | 174 | | |
| | 3 | 175 | | var wanted = permissions.Distinct().ToHashSet(); |
| | | 176 | | if (wanted.Any(x => !Enum.IsDefined(x))) |
| | 0 | 177 | | return PartnerOpResult<PartnerDetailsDto>.Fail("Неизвестная возможность."); |
| | | 178 | | |
| | 5 | 179 | | partner.Permissions.RemoveAll(x => !wanted.Contains(x.Code)); |
| | 19 | 180 | | foreach (var code in wanted.Where(c => partner.Permissions.All(x => x.Code != c))) |
| | 4 | 181 | | partner.Permissions.Add(new PartnerPermission { Code = code }); |
| | | 182 | | |
| | 3 | 183 | | await db.SaveChangesAsync(ct); |
| | 3 | 184 | | await audit.WriteAsync("permissions_saved", PartnerActorKind.Owner, ownerId, partner.Id, |
| | | 185 | | details: string.Join(", ", wanted.OrderBy(x => x)), ct: ct); |
| | 3 | 186 | | return PartnerOpResult<PartnerDetailsDto>.Success(ToDetails(partner)); |
| | 3 | 187 | | } |
| | | 188 | | |
| | | 189 | | public async Task<PartnerOpResult<PartnerDetailsDto>> SaveRulesAsync( |
| | | 190 | | long id, IReadOnlyList<PartnerRuleDto> rules, string ownerId, CancellationToken ct = default) |
| | | 191 | | { |
| | 11 | 192 | | var partner = await LoadAsync(id, tracking: true, ct); |
| | 11 | 193 | | if (partner is null) return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр не найден."); |
| | | 194 | | |
| | 11 | 195 | | var error = ValidateRules(rules); |
| | 18 | 196 | | if (error is not null) return PartnerOpResult<PartnerDetailsDto>.Fail(error); |
| | | 197 | | |
| | 4 | 198 | | partner.Rules.Clear(); |
| | 20 | 199 | | for (var i = 0; i < rules.Count; i++) |
| | | 200 | | { |
| | 6 | 201 | | var rule = rules[i]; |
| | 6 | 202 | | partner.Rules.Add(new PartnerRule |
| | 6 | 203 | | { |
| | 6 | 204 | | Name = RuleDisplayName(rule.Name, i), |
| | 6 | 205 | | IsActive = rule.IsActive, |
| | 6 | 206 | | Conditions = rule.Conditions |
| | | 207 | | .Select(c => new PartnerRuleCondition |
| | | 208 | | { |
| | | 209 | | Field = c.Field, |
| | | 210 | | ValuesJson = JsonSerializer.Serialize(c.Values.Select(v => v.Trim()).ToList()) |
| | | 211 | | }) |
| | 6 | 212 | | .ToList() |
| | 6 | 213 | | }); |
| | | 214 | | } |
| | | 215 | | |
| | 4 | 216 | | await db.SaveChangesAsync(ct); |
| | 4 | 217 | | await audit.WriteAsync("rules_saved", PartnerActorKind.Owner, ownerId, partner.Id, |
| | 4 | 218 | | details: $"Правил: {rules.Count}", ct: ct); |
| | 4 | 219 | | return PartnerOpResult<PartnerDetailsDto>.Success(ToDetails(partner)); |
| | 11 | 220 | | } |
| | | 221 | | |
| | | 222 | | public async Task<PartnerOpResult<bool>> DeleteAsync(long id, string ownerId, CancellationToken ct = default) |
| | | 223 | | { |
| | 1 | 224 | | var partner = await db.Partners.FirstOrDefaultAsync(x => x.Id == id, ct); |
| | 1 | 225 | | if (partner is null) return PartnerOpResult<bool>.Fail("Партнёр не найден."); |
| | | 226 | | |
| | 1 | 227 | | var login = partner.Login; |
| | 1 | 228 | | db.Partners.Remove(partner); |
| | 1 | 229 | | await db.SaveChangesAsync(ct); |
| | | 230 | | // Запись остаётся в журнале без внешнего ключа: история действий партнёра сохраняется. |
| | 1 | 231 | | await audit.WriteAsync("partner_deleted", PartnerActorKind.Owner, ownerId, id, |
| | 1 | 232 | | details: $"Логин: {login}", ct: ct); |
| | 1 | 233 | | return PartnerOpResult<bool>.Success(true); |
| | 1 | 234 | | } |
| | | 235 | | |
| | | 236 | | public static string? ValidateRules(IReadOnlyList<PartnerRuleDto> rules) |
| | | 237 | | { |
| | 16 | 238 | | if (rules.Count > MaxRulesPerPartner) |
| | 0 | 239 | | return $"Правил не больше {MaxRulesPerPartner}."; |
| | | 240 | | |
| | 46 | 241 | | for (var i = 0; i < rules.Count; i++) |
| | | 242 | | { |
| | 18 | 243 | | var rule = rules[i]; |
| | 18 | 244 | | var name = RuleDisplayName(rule.Name, i); |
| | 19 | 245 | | if (name.Length > 200) return "Название правила не длиннее 200 символов."; |
| | 17 | 246 | | if (rule.Conditions.Count == 0) |
| | 1 | 247 | | return $"Правило «{name}»: добавьте хотя бы одно условие. Правило без условий открыло бы все заказы."; |
| | | 248 | | |
| | 53 | 249 | | var duplicate = rule.Conditions.GroupBy(c => c.Field).FirstOrDefault(g => g.Count() > 1); |
| | 16 | 250 | | if (duplicate is not null) |
| | 1 | 251 | | return $"Правило «{name}»: поле «{duplicate.Key}» указано дважды, объедините значения в одно условие."; |
| | | 252 | | |
| | 56 | 253 | | foreach (var condition in rule.Conditions) |
| | | 254 | | { |
| | 17 | 255 | | if (!Enum.IsDefined(condition.Field)) return $"Правило «{name}»: неизвестное поле."; |
| | 40 | 256 | | var values = condition.Values.Select(v => (v ?? string.Empty).Trim()).ToList(); |
| | 17 | 257 | | if (values.Count > MaxValuesPerCondition) |
| | 0 | 258 | | return $"Правило «{name}»: не больше {MaxValuesPerCondition} значений в условии."; |
| | | 259 | | |
| | 17 | 260 | | if (condition.Field == PartnerRuleField.DatePeriod) |
| | | 261 | | { |
| | 4 | 262 | | var periodError = ValidatePeriod(name, values); |
| | 7 | 263 | | if (periodError is not null) return periodError; |
| | | 264 | | } |
| | 28 | 265 | | else if (values.Count == 0 || values.Any(v => v.Length is 0 or > 200)) |
| | | 266 | | { |
| | 1 | 267 | | return $"Правило «{name}»: у поля «{condition.Field}» нужны непустые значения до 200 символов."; |
| | | 268 | | } |
| | 12 | 269 | | else if (condition.Field is PartnerRuleField.Status or PartnerRuleField.Warehouse or PartnerRuleField.Cl |
| | 22 | 270 | | && values.Any(v => !int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) |
| | | 271 | | { |
| | 2 | 272 | | return $"Правило «{name}»: значения поля «{condition.Field}» — числовые идентификаторы."; |
| | | 273 | | } |
| | 10 | 274 | | else if (condition.Field == PartnerRuleField.Marketplace |
| | 15 | 275 | | && values.Any(v => !Enum.TryParse<OzonDomains.ClientType>(v, true, out var t) || t == OzonDomai |
| | | 276 | | { |
| | 2 | 277 | | return $"Правило «{name}»: маркетплейс — OZON или YANDEX."; |
| | | 278 | | } |
| | | 279 | | } |
| | | 280 | | } |
| | | 281 | | |
| | 5 | 282 | | return null; |
| | 8 | 283 | | } |
| | | 284 | | |
| | | 285 | | /// <summary>Название правила необязательно: пустое получает «Правило N» по порядку.</summary> |
| | | 286 | | internal static string RuleDisplayName(string? name, int index) |
| | | 287 | | { |
| | 24 | 288 | | var trimmed = (name ?? string.Empty).Trim(); |
| | 24 | 289 | | return trimmed.Length == 0 ? $"Правило {index + 1}" : trimmed; |
| | | 290 | | } |
| | | 291 | | |
| | | 292 | | private static string? ValidatePeriod(string ruleName, List<string> values) |
| | | 293 | | { |
| | 9 | 294 | | if (values.Count != 2 || values.All(v => v.Length == 0)) |
| | 1 | 295 | | return $"Правило «{ruleName}»: период задаётся двумя значениями «с» и «по», хотя бы одно заполнено."; |
| | | 296 | | |
| | 6 | 297 | | DateTime? from = null, to = null; |
| | 14 | 298 | | for (var i = 0; i < 2; i++) |
| | | 299 | | { |
| | 5 | 300 | | if (values[i].Length == 0) continue; |
| | 4 | 301 | | if (!DateTime.TryParseExact(values[i], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out |
| | 1 | 302 | | return $"Правило «{ruleName}»: дата «{values[i]}» не в формате гггг-мм-дд."; |
| | 6 | 303 | | if (i == 0) from = d; else to = d; |
| | | 304 | | } |
| | | 305 | | |
| | 2 | 306 | | return from is not null && to is not null && from > to |
| | 2 | 307 | | ? $"Правило «{ruleName}»: начало периода позже конца." |
| | 2 | 308 | | : null; |
| | | 309 | | } |
| | | 310 | | |
| | | 311 | | private static (string? Email, string? Error) NormalizeGoogleEmail(string? raw) |
| | | 312 | | { |
| | 83 | 313 | | if (string.IsNullOrWhiteSpace(raw)) return (null, null); |
| | 3 | 314 | | var email = raw.Trim().ToLowerInvariant(); |
| | 3 | 315 | | return IsValidEmail(email) ? (email, null) : (null, "Некорректная Google-почта."); |
| | | 316 | | } |
| | | 317 | | |
| | | 318 | | private static bool IsValidEmail(string value) |
| | | 319 | | { |
| | 3 | 320 | | if (value.Length > 320) return false; |
| | 3 | 321 | | return MailAddress.TryCreate(value, out var addr) && addr.Address == value; |
| | | 322 | | } |
| | | 323 | | |
| | 4 | 324 | | private static void RotateSecurityStamp(Partner partner) => partner.SecurityStamp = Guid.NewGuid().ToString("N"); |
| | | 325 | | |
| | | 326 | | private Task<Partner?> LoadAsync(long id, bool tracking, CancellationToken ct) |
| | | 327 | | { |
| | 22 | 328 | | IQueryable<Partner> q = db.Partners |
| | 22 | 329 | | .Include(x => x.Permissions) |
| | 22 | 330 | | .Include(x => x.Rules).ThenInclude(r => r.Conditions) |
| | 22 | 331 | | .AsSplitQuery(); |
| | 22 | 332 | | if (!tracking) q = q.AsNoTracking(); |
| | 22 | 333 | | return q.FirstOrDefaultAsync(x => x.Id == id, ct); |
| | | 334 | | } |
| | | 335 | | |
| | 47 | 336 | | private static PartnerDetailsDto ToDetails(Partner p) => new( |
| | 47 | 337 | | p.Id, p.Name, p.Login, p.Email, p.GoogleEmail, p.IsActive, p.CreatedAtUtc, p.LastLoginAtUtc, |
| | 47 | 338 | | p.NotificationChannels, p.NotificationEmail, p.TelegramChatId, |
| | 10 | 339 | | p.Permissions.Select(x => x.Code).OrderBy(x => x).ToList(), |
| | 12 | 340 | | p.Rules.OrderBy(r => r.Id).Select(r => new PartnerRuleDto( |
| | 53 | 341 | | r.Id, r.Name, r.IsActive, |
| | 14 | 342 | | r.Conditions.OrderBy(c => c.Field).Select(c => new PartnerRuleConditionDto( |
| | 60 | 343 | | c.Field, |
| | 60 | 344 | | JsonSerializer.Deserialize<List<string>>(c.ValuesJson) ?? new List<string>())).ToList())).ToList()); |
| | | 345 | | } |