using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Modules.Discovery.Application.Services;
///
/// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete.
///
public sealed class DiscoveryCandidatesService(
IDiscoveryStore store,
DiscoveryLogService log,
DiscoveryBlacklistService blacklist)
{
///
/// 400 mark_rejected
///
public const string RejectJoinedDetail = "Нельзя отклонить источник, в который уже вступили";
///
/// 400 set_candidate_status
///
public const string TransitionNotAllowedFormat = "Статус {0} выставляется через mark_joined/mark_rejected";
private const string SkipMonitoredFormat = "пропущен {0}: источник уже мониторится (мы состоим)";
private const string SkipBlacklistedFormat = "пропущен {0}: источник в чёрном списке";
private const string SkipActiveFormat = "пропущен {0}: кандидат уже есть (статус {1})";
private const string ReviewLogFormat = "кандидат {0} переведён в review";
private const string JoinedLogFormat = "вступили в {0}";
private const string RejectedLogFormat = "отклонён {0}";
///
/// Кандидаты задачи, старые первыми.
///
/// Id задачи (dt_...).
/// Статус-фильтр (new|review|joined|rejected); null — все.
/// Кандидаты задачи (marks/topics — списками).
public Task> ListAsync(
string taskId,
string? status,
CancellationToken ct)
{
return store.ListCandidatesAsync(taskId, status, ct);
}
///
/// Кандидат по dialog_id.
///
/// Подписанный id источника.
/// Кандидат или null (404 «Кандидат не найден»).
public Task GetAsync(string dialogId, CancellationToken ct)
{
return store.GetCandidateAsync(dialogId, ct);
}
///
/// Добавляет найденный источник как кандидата задачи.
///
/// Id задачи (dt_...).
/// Подписанный id источника.
/// Имя источника (пустое → DialogId).
/// Username источника (пуст, если нет публичного).
/// Тип источника: channel|group|forum (пусто → channel).
/// Цвет источника (пусто → «#666»).
/// Новый кандидат либо null — источник пропущен (в лог записан skip).
public async Task AddAsync(
string taskId,
string dialogId,
string name,
string username,
string kind,
string hue,
CancellationToken ct)
{
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
if (task is null)
{
return null;
}
if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false))
{
await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipMonitoredFormat, dialogId), ct)
.ConfigureAwait(false);
return null;
}
if (await store.IsBlacklistedAsync(dialogId, ct).ConfigureAwait(false))
{
await log.AddAsync(taskId, DiscoveryLogEvents.Skip, string.Format(SkipBlacklistedFormat, dialogId), ct)
.ConfigureAwait(false);
return null;
}
DiscoveryCandidateDto? existing = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (existing is not null && DiscoveryCandidateStatuses.IsActive(existing.Status))
{
await log.AddAsync(
taskId,
DiscoveryLogEvents.Skip,
string.Format(SkipActiveFormat, dialogId, existing.Status),
ct).ConfigureAwait(false);
return null;
}
if (existing is not null)
{
await store.DeleteCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
var row = new DiscoveryCandidateRow
{
DialogId = dialogId,
TaskId = taskId,
Name = TrimOr(name, dialogId),
Username = TrimOr(username, string.Empty),
Kind = TrimOr(kind, DiscoveryCandidateKinds.Channel),
Hue = TrimOr(hue, SourceDefaults.DefaultHue),
};
await store.CreateCandidateAsync(row, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
///
/// Обновляет поля кандидата по результатам оценки.
///
/// Id задачи.
/// Подписанный id источника.
/// Изменяемые поля (null — не менять).
/// Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).
public async Task SetAsync(
string taskId,
string dialogId,
DiscoveryCandidatePatch patch,
CancellationToken ct)
{
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false);
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (task is null || current is null || current.TaskId != taskId)
{
return null;
}
DiscoveryCandidatePatch normalized = NormalizePatch(patch, current);
bool hasChanges = normalized.Name is not null || normalized.Username is not null || normalized.Kind is not null
|| normalized.Hue is not null || normalized.Participants is not null || normalized.LangRu is not null
|| normalized.Marks is not null || normalized.Topics is not null || normalized.FitRatio is not null
|| normalized.AutoJoined is not null;
if (!hasChanges)
{
return current;
}
await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
///
/// Переводит кандидата в new/review.
///
/// Подписанный id источника.
/// Новый статус: new|review.
/// Обновлённый кандидат либо null (кандидата нет).
/// Статус не new/review.
public async Task SetStatusAsync(
string dialogId,
string status,
CancellationToken ct)
{
if (!DiscoveryCandidateStatuses.IsTransitionAllowed(status))
{
throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status));
}
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false);
if (status == DiscoveryCandidateStatuses.Review)
{
await log.AddAsync(
current.TaskId,
DiscoveryLogEvents.Review,
string.Format(ReviewLogFormat, dialogId),
ct).ConfigureAwait(false);
}
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
///
/// Вступили в источник
///
/// Подписанный id источника.
/// True — авто-вступление воркера; false — ручное.
/// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен.
public async Task MarkJoinedAsync(
string dialogId,
bool auto,
CancellationToken ct)
{
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
if (current.Status == DiscoveryCandidateStatuses.Joined)
{
return current;
}
await store.SetCandidateJoinedAsync(dialogId, auto, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false);
string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual;
await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
///
/// Отклоняет кандидата
///
/// Подписанный id источника.
/// Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).
/// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен.
/// Источник уже joined — отклонить нельзя.
public async Task MarkRejectedAsync(
string dialogId,
string reason,
CancellationToken ct)
{
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
if (current is null)
{
return null;
}
if (current.Status == DiscoveryCandidateStatuses.Joined)
{
throw new DiscoveryValidationException(RejectJoinedDetail);
}
if (current.Status == DiscoveryCandidateStatuses.Rejected)
{
return current;
}
await store.SetCandidateRejectedAsync(dialogId, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Rejected, 1, ct).ConfigureAwait(false);
string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason;
await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false);
await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false);
}
///
/// Удаляет кандидата.
///
/// Подписанный id источника.
/// Завершается после удаления строки.
public Task DeleteAsync(string dialogId, CancellationToken ct)
{
return store.DeleteCandidateAsync(dialogId, ct);
}
private static DiscoveryCandidatePatch NormalizePatch(DiscoveryCandidatePatch patch, DiscoveryCandidateDto current)
{
return new DiscoveryCandidatePatch
{
Name = NormalizeKeptOr(patch.Name, current.Name),
Username = NormalizeKeptOr(patch.Username, current.Username),
Kind = NormalizeKeptOr(patch.Kind, current.Kind),
Hue = NormalizeKeptOr(patch.Hue, current.Hue),
Participants = patch.Participants,
LangRu = patch.LangRu,
Marks = patch.Marks?.Select(mark => (string)mark).ToList(),
Topics = patch.Topics?.ToList(),
FitRatio = patch.FitRatio,
AutoJoined = patch.AutoJoined,
};
}
private static string? NormalizeKeptOr(string? value, string current)
{
if (value is null)
{
return null;
}
string trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
private static string TrimOr(string value, string fallback)
{
string trimmed = (value ?? string.Empty).Trim();
return trimmed.Length == 0 ? fallback : trimmed;
}
}