9 Commits
Author SHA1 Message Date
rust 585397b9a3 Merge pull request 'Перевести оставшиеся «не найдено» в доменных сервисах на DealException' (#26) from t21_notfound_exceptions into main
ci / build-test (push) Successful in 2m56s
Reviewed-on: #26
2026-09-13 18:58:39 +03:00
stepan 75de70e09c Перевести «не найдено» пользователя у оператора на исключение
ci / build-test (pull_request) Successful in 3m7s
TenantAdminService.GetAsync бросает NotFoundException вместо null; эндпоинт деталей без 404-проверки.
2026-09-13 18:49:18 +03:00
stepan 891a894aed Перевести «не найдено» ML/Pipeline на NotFoundException
MlReviewService.ApplyAsync (исходное сообщение) и PipelineProcessingService.ReturnAsync (запись отсева) бросают NotFoundException; эндпоинты без 404-проверок.
2026-09-13 18:44:33 +03:00
stepan f93fb0fdd3 Перевести «не найдено» кандидатов Discovery на исключения
DiscoveryCandidatesService (get/set/status/mark_joined/mark_rejected/add при отсутствии задачи) бросает NotFoundException; skip-ветки add оставлены null. Эндпоинты без локальных 404-проверок.
2026-09-13 18:27:18 +03:00
stepan d2d6b81aa0 Перевести «не найдено» карточек на NotFoundException
CardsService (get/move/links/reminders) и CardMover бросают NotFoundException вместо null/CardResultDto(null,null)/Exists=false. CardMoveResultDto — только Error; эндпоинты без локальных 404-проверок.
2026-09-13 18:23:53 +03:00
rust a9bfecf9cc Merge pull request 'Поднять dev-стек в Docker (фронт + observability) и починить подключение Telegram' (#24) from t23_dev_stack_telegram into main
ci / build-test (push) Successful in 2m55s
Reviewed-on: #24
2026-09-13 17:58:14 +03:00
stepan 13d7994511 Обновить тест статуса Telegram под новую семантику
ci / build-test (pull_request) Successful in 2m50s
connected = phase ready; живой account приоритетнее KV tgAccount.
2026-09-13 16:27:19 +03:00
stepan a9f2b3a1ef Починить QR-вход и реактивный статус Telegram
ci / build-test (pull_request) Failing after 1m58s
tgStart ждёт старт QR до показа картинки (устранена гонка с 404). Вкладка Telegram опрашивает статус и обновляет QR по таймеру, не останавливаясь до авторизации. Статус: account берётся из живого статуса сервиса, connected = phase ready (авторизация), а не транспорт.
2026-09-13 16:22:20 +03:00
stepan a9bcd7c5f7 Поднять фронт и observability в dev-стеке
Сервис frontend: сборка SPA (Vite) и отдача через Caddy, /api → core:5080. В профиль observability добавлены Loki/Promtail/Grafana (в dev их не было). Dev-Caddyfile отдаёт SPA с Cache-Control no-cache, чтобы пересборка подхватывалась обновлением страницы.
2026-09-13 16:22:16 +03:00
32 changed files with 391 additions and 322 deletions
+19
View File
@@ -0,0 +1,19 @@
# Dev-edge «Дейла» (compose.dev.yml, сервис frontend): SPA + /api на core.
# Отличие от prod-Caddyfile: HTTP без TLS и без плейсхолдер-домена — для локального просмотра UI.
# Статика — собранный SPA (Vite) в /srv, неизвестные пути отдают index.html (история браузера).
:80 {
# API core: /api/* уходит на core:5080 без перезаписи (контракт /api неизменен).
# SSE (/api/events), файлы и QR-SVG проходят reverse_proxy потоково.
handle /api/* {
reverse_proxy core:5080
}
handle {
# SPA/ассеты в dev не кэшируем: пересборка фронта должна подхватываться по F5.
header Cache-Control "no-cache"
root * /srv
try_files {path} /index.html
file_server
}
}
+65
View File
@@ -254,6 +254,18 @@ services:
timeout: 3s timeout: 3s
retries: 10 retries: 10
# Фронтенд (SPA) — сборка образа (Vite) и отдача через Caddy; /api → core:5080. UI — http://localhost:8080.
frontend:
build:
context: ..
dockerfile: src/frontend/Dockerfile
container_name: deal-frontend
ports:
- "8080:80"
depends_on:
core:
condition: service_healthy
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464) # Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d. # внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090. # Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
@@ -332,6 +344,56 @@ services:
- /:/host/root:ro - /:/host/root:ro
pid: host pid: host
# Loki — хранилище логов (профиль observability), UI/API — :3100.
loki:
image: grafana/loki:3.4.2
container_name: deal-loki
profiles: ["observability"]
command: -config.file=/etc/loki/loki.yml
ports:
- "3100:3100"
volumes:
- ./observability/loki.yml:/etc/loki/loki.yml:ro
- deal_loki_data:/loki
# Promtail — сбор docker-логов deal-процессов в Loki (docker.sock, профиль observability).
promtail:
image: grafana/promtail:3.4.2
container_name: deal-promtail
profiles: ["observability"]
command: -config.file=/etc/promtail/promtail.yml
volumes:
- ./observability/promtail.yml:/etc/promtail/promtail.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- deal_promtail_data:/var/lib/promtail
depends_on:
loki:
condition: service_started
# Grafana — UI логов/метрик/трейсов (профиль observability), локальный вход admin/admin.
grafana:
image: grafana/grafana:11.5.2
container_name: deal-grafana
profiles: ["observability"]
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
ports:
- "3001:3000"
volumes:
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- deal_grafana_data:/var/lib/grafana
depends_on:
loki:
condition: service_started
prometheus:
condition: service_started
tempo:
condition: service_started
volumes: volumes:
deal_pgdata: deal_pgdata:
deal_minio_data: deal_minio_data:
@@ -340,3 +402,6 @@ volumes:
deal_api_data: deal_api_data:
deal_prometheus_data: deal_prometheus_data:
deal_tempo_data: deal_tempo_data:
deal_loki_data:
deal_promtail_data:
deal_grafana_data:
@@ -202,9 +202,7 @@ public static class CardDetailsEndpoints
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
} }
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
@@ -220,10 +218,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct); await service.RemoveLinkAsync(cardId, linkId, ct);
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files). // POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
@@ -240,10 +236,7 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
if (await cardsService.GetCardAsync(cardId, ct) is null) await cardsService.GetCardAsync(cardId, ct);
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
IFormCollection form; IFormCollection form;
try try
@@ -357,9 +350,7 @@ public static class CardDetailsEndpoints
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
} }
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
@@ -374,9 +365,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.ClearReminderAsync(cardId, ct) await service.ClearReminderAsync(cardId, ct);
? await ReadCardAsync(context, cardId, ct) return await ReadCardAsync(context, cardId, ct);
: EndpointResults.NotFound(CardNotFoundDetail);
} }
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
@@ -391,9 +381,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.SnoozeReminderAsync(cardId, ct) await service.SnoozeReminderAsync(cardId, ct);
? await ReadCardAsync(context, cardId, ct) return await ReadCardAsync(context, cardId, ct);
: EndpointResults.NotFound(CardNotFoundDetail);
} }
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет). // Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
@@ -412,11 +401,7 @@ public static class CardDetailsEndpoints
CancellationToken ct) CancellationToken ct)
{ {
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
SourceContent content = await cardsService.ResolveSourceAsync(card, ct); SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
return Results.Ok(content); return Results.Ok(content);
@@ -428,10 +413,8 @@ public static class CardDetailsEndpoints
CancellationToken ct) CancellationToken ct)
{ {
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
return card is null return Results.Ok(card);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
} }
// Имя файла для Content-Disposition без кавычек «"». // Имя файла для Content-Disposition без кавычек «"».
+5 -18
View File
@@ -128,10 +128,8 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
return card is null return Results.Ok(card);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
} }
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
@@ -184,18 +182,11 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error); return EndpointResults.BadRequest(outcome.Error);
} }
if (!outcome.Exists)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? unified = await cardsService.GetCardAsync(cardId, ct); CardDto unified = await cardsService.GetCardAsync(cardId, ct);
return unified is null return Results.Ok(unified);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(unified);
} }
private static async Task<IResult> TrashAsync( private static async Task<IResult> TrashAsync(
@@ -353,11 +344,7 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>(); CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct); ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
@@ -55,9 +55,6 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET). // Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log"; private const string TaskLogPath = "/tasks/{task_id}/log";
// 404: кандидат не найден.
private const string CandidateNotFoundDetail = "Кандидат не найден";
private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
@@ -281,11 +278,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined) if (row.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -314,8 +307,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct); DiscoveryCandidateDto joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined); return Results.Ok(joined);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -334,11 +327,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined) if (row.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -347,8 +336,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct); DiscoveryCandidateDto rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected); return Results.Ok(rejected);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
+1 -8
View File
@@ -37,8 +37,6 @@ public static class MlEndpoints
private const string EnterTextDetail = "Введите текст"; private const string EnterTextDetail = "Введите текст";
private const string MessageNotFoundDetail = "Исходное сообщение не найдено";
/// <summary> /// <summary>
/// Регистрирует группу /api/ml /// Регистрирует группу /api/ml
/// </summary> /// </summary>
@@ -141,12 +139,7 @@ public static class MlEndpoints
} }
MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>(); MlReviewService review = context.RequestServices.GetRequiredService<MlReviewService>();
MlApplyResult? result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct); MlApplyResult result = await review.ApplyAsync(body.DialogId, body.MsgId, body.Action, ct);
if (result is null)
{
return EndpointResults.NotFound(MessageNotFoundDetail);
}
if (result.Error is not null) if (result.Error is not null)
{ {
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
@@ -144,12 +144,7 @@ public static class OperatorTenantsEndpoints
return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail);
} }
TenantDetailDto? tenant = await tenantAdminService.GetAsync(id, ct); TenantDetailDto tenant = await tenantAdminService.GetAsync(id, ct);
if (tenant is null)
{
return EndpointResults.NotFound(TenantNotFoundDetail);
}
return Results.Ok(tenant); return Results.Ok(tenant);
} }
@@ -33,8 +33,6 @@ public static class PipelineEndpoints
// Путь возврата записи отсева в обработку (POST). // Путь возврата записи отсева в обработку (POST).
private const string RejectedReturnPath = "/rejected/{rejId}/return"; private const string RejectedReturnPath = "/rejected/{rejId}/return";
private const string RejectedNotFoundDetail = "Запись не найдена";
private const int DefaultPageSize = 100; private const int DefaultPageSize = 100;
/// <summary> /// <summary>
@@ -140,11 +138,7 @@ public static class PipelineEndpoints
} }
PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>(); PipelineProcessingService processing = context.RequestServices.GetRequiredService<PipelineProcessingService>();
RejectReturnResultDto? result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct); RejectReturnResultDto result = await processing.ReturnAsync(rejId, body.Reason ?? string.Empty, ct);
if (result is null)
{
return EndpointResults.NotFound(RejectedNotFoundDetail);
}
return result.Error is not null return result.Error is not null
? EndpointResults.BadRequest(result.Error) ? EndpointResults.BadRequest(result.Error)
@@ -22,6 +22,7 @@ public sealed class TgStatusService(
TelegramKeysService keys) TelegramKeysService keys)
{ {
private const string IdlePhase = "idle"; private const string IdlePhase = "idle";
private const string ReadyPhase = "ready";
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
private static readonly JsonSerializerOptions KvJsonOptions = new() private static readonly JsonSerializerOptions KvJsonOptions = new()
@@ -37,13 +38,19 @@ public sealed class TgStatusService(
public async Task<TgStatusDto> GetAsync(CancellationToken ct) public async Task<TgStatusDto> GetAsync(CancellationToken ct)
{ {
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false); TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
string account = await ReadAccountAsync(ct).ConfigureAwait(false); // «Подключён» для UI = авторизован (phase ready). Транспортный connected сервиса
// означает лишь живость соединения и не гарантирует вход — в UI он даёт «зависание».
bool authorized = string.Equals(live.Phase, ReadyPhase, StringComparison.Ordinal);
// Живой account (имя из Telegram) приоритетнее KV: при QR-входе KV ещё не заполнен.
string account = string.IsNullOrEmpty(live.Account)
? await ReadAccountAsync(ct).ConfigureAwait(false)
: live.Account;
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count; int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
return new TgStatusDto( return new TgStatusDto(
Phase: live.Phase, Phase: live.Phase,
Connected: live.Connected, Connected: authorized,
Listener: live.Listener, Listener: live.Listener,
Account: account, Account: account,
Monitored: monitored, Monitored: monitored,
@@ -22,8 +22,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
CardResultDto result = CardsDefaultContainers.Contains(toContainerId) CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
return result.Error is not null return new CardMoveResultDto(result.Error);
? new CardMoveResultDto(result.Error, Exists: true)
: new CardMoveResultDto(null, Exists: result.Card is not null);
} }
} }
@@ -1,5 +1,6 @@
using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Cards.Application.Abstractions; namespace Deal.Modules.Cards.Application.Abstractions;
@@ -14,7 +15,8 @@ public interface ICardMover
/// <param name="cardId">Id карточки.</param> /// <param name="cardId">Id карточки.</param>
/// <param name="toContainerId">Id контейнера назначения (стадия «Выбранных» либо дашборд-контейнер).</param> /// <param name="toContainerId">Id контейнера назначения (стадия «Выбранных» либо дашборд-контейнер).</param>
/// <param name="ctx">Контекст перехода (инициатор, причина, обучение).</param> /// <param name="ctx">Контекст перехода (инициатор, причина, обучение).</param>
/// <returns>Результат: Error (400-текст отказа) | Exists=false (карточки нет, 404) | успех (Exists=true).</returns> /// <returns>Результат: Error (400-текст отказа) либо null (успех).</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public Task<CardMoveResultDto> MoveAsync( public Task<CardMoveResultDto> MoveAsync(
string cardId, string cardId,
string toContainerId, string toContainerId,
@@ -5,6 +5,5 @@ namespace Deal.Modules.Cards.Application.Dtos;
/// <summary> /// <summary>
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>. /// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
/// </summary> /// </summary>
/// <param name="Error">Текст 400-ошибки либо null.</param> /// <param name="Error">Текст 400-ошибки либо null (успех).</param>
/// <param name="Exists">True — карточка найдена и переход выполнен (либо перенос был no-op).</param> public sealed record CardMoveResultDto(string? Error);
public sealed record CardMoveResultDto(string? Error, bool Exists);
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Abstractions; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
@@ -13,6 +14,10 @@ public sealed class DiscoveryCandidatesService(
DiscoveryLogService log, DiscoveryLogService log,
DiscoveryBlacklistService blacklist) DiscoveryBlacklistService blacklist)
{ {
private const string CandidateEntityName = "Кандидат";
private const string TaskEntityName = "Задача поиска";
/// <summary> /// <summary>
/// 400 mark_rejected /// 400 mark_rejected
/// </summary> /// </summary>
@@ -53,10 +58,12 @@ public sealed class DiscoveryCandidatesService(
/// Кандидат по dialog_id. /// Кандидат по dialog_id.
/// </summary> /// </summary>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <returns>Кандидат или null (404 «Кандидат не найден»).</returns> /// <returns>Кандидат.</returns>
public Task<DiscoveryCandidateDto?> GetAsync(string dialogId, CancellationToken ct) /// <exception cref="NotFoundException">Кандидат не найден.</exception>
public async Task<DiscoveryCandidateDto> GetAsync(string dialogId, CancellationToken ct)
{ {
return store.GetCandidateAsync(dialogId, ct); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -78,11 +85,8 @@ public sealed class DiscoveryCandidatesService(
string hue, string hue,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (task is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return null;
}
if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false)) if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false))
{ {
@@ -125,7 +129,8 @@ public sealed class DiscoveryCandidatesService(
}; };
await store.CreateCandidateAsync(row, ct).ConfigureAwait(false); await store.CreateCandidateAsync(row, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false); await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -134,18 +139,21 @@ public sealed class DiscoveryCandidatesService(
/// <param name="taskId">Id задачи.</param> /// <param name="taskId">Id задачи.</param>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <param name="patch">Изменяемые поля (null — не менять).</param> /// <param name="patch">Изменяемые поля (null — не менять).</param>
/// <returns>Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).</returns> /// <returns>Обновлённый кандидат.</returns>
public async Task<DiscoveryCandidateDto?> SetAsync( /// <exception cref="NotFoundException">Задача или кандидат не найдены.</exception>
public async Task<DiscoveryCandidateDto> SetAsync(
string taskId, string taskId,
string dialogId, string dialogId,
DiscoveryCandidatePatch patch, DiscoveryCandidatePatch patch,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); _ = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); ?? throw new NotFoundException(TaskEntityName, taskId);
if (task is null || current is null || current.TaskId != taskId) DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
if (current.TaskId != taskId)
{ {
return null; throw new NotFoundException(CandidateEntityName, dialogId);
} }
DiscoveryCandidatePatch normalized = NormalizePatch(patch, current); DiscoveryCandidatePatch normalized = NormalizePatch(patch, current);
@@ -159,7 +167,8 @@ public sealed class DiscoveryCandidatesService(
} }
await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false); await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -167,9 +176,10 @@ public sealed class DiscoveryCandidatesService(
/// </summary> /// </summary>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <param name="status">Новый статус: new|review.</param> /// <param name="status">Новый статус: new|review.</param>
/// <returns>Обновлённый кандидат либо null (кандидата нет).</returns> /// <returns>Обновлённый кандидат.</returns>
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
/// <exception cref="DiscoveryValidationException">Статус не new/review.</exception> /// <exception cref="DiscoveryValidationException">Статус не new/review.</exception>
public async Task<DiscoveryCandidateDto?> SetStatusAsync( public async Task<DiscoveryCandidateDto> SetStatusAsync(
string dialogId, string dialogId,
string status, string status,
CancellationToken ct) CancellationToken ct)
@@ -179,11 +189,8 @@ public sealed class DiscoveryCandidatesService(
throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status)); throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status));
} }
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(CandidateEntityName, dialogId);
{
return null;
}
await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false); await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false);
if (status == DiscoveryCandidateStatuses.Review) if (status == DiscoveryCandidateStatuses.Review)
@@ -195,7 +202,8 @@ public sealed class DiscoveryCandidatesService(
ct).ConfigureAwait(false); ct).ConfigureAwait(false);
} }
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -203,17 +211,15 @@ public sealed class DiscoveryCandidatesService(
/// </summary> /// </summary>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <param name="auto">True — авто-вступление воркера; false — ручное.</param> /// <param name="auto">True — авто-вступление воркера; false — ручное.</param>
/// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен.</returns> /// <returns>Кандидат в joined. Повторный вызов для joined — идемпотентен.</returns>
public async Task<DiscoveryCandidateDto?> MarkJoinedAsync( /// <exception cref="NotFoundException">Кандидат не найден.</exception>
public async Task<DiscoveryCandidateDto> MarkJoinedAsync(
string dialogId, string dialogId,
bool auto, bool auto,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(CandidateEntityName, dialogId);
{
return null;
}
if (current.Status == DiscoveryCandidateStatuses.Joined) if (current.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -224,7 +230,8 @@ public sealed class DiscoveryCandidatesService(
await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false); await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false);
string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual; string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual;
await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false); await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -232,18 +239,16 @@ public sealed class DiscoveryCandidatesService(
/// </summary> /// </summary>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param> /// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param>
/// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен.</returns> /// <returns>Кандидат в rejected. Повторный вызов для rejected — идемпотентен.</returns>
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
/// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception> /// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception>
public async Task<DiscoveryCandidateDto?> MarkRejectedAsync( public async Task<DiscoveryCandidateDto> MarkRejectedAsync(
string dialogId, string dialogId,
string reason, string reason,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(CandidateEntityName, dialogId);
{
return null;
}
if (current.Status == DiscoveryCandidateStatuses.Joined) if (current.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -260,7 +265,8 @@ public sealed class DiscoveryCandidatesService(
string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason; string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason;
await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false); await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false);
await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false); await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -27,9 +27,16 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns> /// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct) /// <summary>
/// Одна карточка по id.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> GetCardAsync(string cardId, CancellationToken ct)
{ {
return _store.GetCardAsync(cardId, ct); return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
} }
@@ -57,7 +64,7 @@ public sealed partial class CardsService
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null) if (card is null)
{ {
return new CardResultDto(null, null); throw new NotFoundException(CardEntityName, cardId);
} }
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash) if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
@@ -81,7 +88,10 @@ public sealed partial class CardsService
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct); await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
} }
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct)); return new CardResultDto(
null,
await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId));
} }
/// <summary> /// <summary>
@@ -1,5 +1,6 @@
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -22,17 +23,15 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="atMs">Время напоминания, epoch-ms.</param> /// <param name="atMs">Время напоминания, epoch-ms.</param>
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card=null без Error (404) | Card — карточка с напоминанием.</returns> /// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card — карточка с напоминанием.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> SetReminderAsync( public async Task<CardResultDto> SetReminderAsync(
string cardId, string cardId,
long atMs, long atMs,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return new CardResultDto(null, null);
}
if (!await ReadRemindersEnabledAsync(ct)) if (!await ReadRemindersEnabledAsync(ct))
{ {
@@ -41,7 +40,7 @@ public sealed partial class CardsService
await _store.SetReminderAsync(cardId, atMs, ct); await _store.SetReminderAsync(cardId, atMs, ct);
CardDto saved = await _store.GetCardAsync(cardId, ct) CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после установки напоминания: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved); return new CardResultDto(null, saved);
} }
@@ -49,35 +48,27 @@ public sealed partial class CardsService
/// Снимает напоминание карточки. /// Снимает напоминание карточки.
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>True — карточка есть и напоминание снято; false — карточки нет (404).</returns> /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<bool> ClearReminderAsync(string cardId, CancellationToken ct) public async Task ClearReminderAsync(string cardId, CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); _ = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return false;
}
await _store.ClearReminderAsync(cardId, ct); await _store.ClearReminderAsync(cardId, ct);
return true;
} }
/// <summary> /// <summary>
/// «Напомнить позже» /// «Напомнить позже»
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>True — карточка есть и напоминание отложено; false — карточки нет (404).</returns> /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<bool> SnoozeReminderAsync(string cardId, CancellationToken ct) public async Task SnoozeReminderAsync(string cardId, CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); _ = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return false;
}
long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs; long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs;
await _store.SetReminderAsync(cardId, snoozedAtMs, ct); await _store.SetReminderAsync(cardId, snoozedAtMs, ct);
return true;
} }
/// <summary> /// <summary>
@@ -12,6 +12,9 @@ namespace Deal.Modules.Kanban.Application.Services;
/// </summary> /// </summary>
public sealed partial class CardsService public sealed partial class CardsService
{ {
// Имя сущности для текста ошибки «не найдено».
private const string CardLinkEntityName = "Ссылка карточки";
/// <summary> /// <summary>
/// 400 перенос по стадии /// 400 перенос по стадии
/// </summary> /// </summary>
@@ -159,18 +162,16 @@ public sealed partial class CardsService
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="name">Название ссылки; пустое после Trim → name = url.</param> /// <param name="name">Название ссылки; пустое после Trim → name = url.</param>
/// <param name="url">URL ссылки (без схемы — добавится https://).</param> /// <param name="url">URL ссылки (без схемы — добавится https://).</param>
/// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns> /// <returns>Результат: Error (400 «Пустая ссылка») | Card — карточка со ссылкой.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> AddLinkAsync( public async Task<CardResultDto> AddLinkAsync(
string cardId, string cardId,
string name, string name,
string url, string url,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return new CardResultDto(null, null);
}
string normalizedUrl = (url ?? string.Empty).Trim(); string normalizedUrl = (url ?? string.Empty).Trim();
if (normalizedUrl.Length == 0) if (normalizedUrl.Length == 0)
@@ -191,11 +192,11 @@ public sealed partial class CardsService
normalizedUrl); normalizedUrl);
if (!await _store.AddLinkAsync(cardId, link, ct)) if (!await _store.AddLinkAsync(cardId, link, ct))
{ {
return new CardResultDto(null, null); throw new NotFoundException(CardEntityName, cardId);
} }
CardDto saved = await _store.GetCardAsync(cardId, ct) CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved); return new CardResultDto(null, saved);
} }
@@ -204,7 +205,8 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param> /// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns> /// <returns>Карточка без ссылки.</returns>
/// <exception cref="NotFoundException">Карточка или ссылка не найдены.</exception>
public async Task<CardResultDto> RemoveLinkAsync( public async Task<CardResultDto> RemoveLinkAsync(
string cardId, string cardId,
string linkId, string linkId,
@@ -212,11 +214,11 @@ public sealed partial class CardsService
{ {
if (!await _store.RemoveLinkAsync(cardId, linkId, ct)) if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
{ {
return new CardResultDto(null, null); throw new NotFoundException(CardLinkEntityName, linkId);
} }
CardDto saved = await _store.GetCardAsync(cardId, ct) CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved); return new CardResultDto(null, saved);
} }
@@ -225,7 +227,8 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param> /// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса.</returns> /// <returns>Результат: Error «Неизвестная стадия» (400) | Card — карточка после переноса.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> MoveStageCardAsync( public async Task<CardResultDto> MoveStageCardAsync(
string cardId, string cardId,
string containerId, string containerId,
@@ -241,11 +244,11 @@ public sealed partial class CardsService
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct); bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
if (!moved) if (!moved)
{ {
return new CardResultDto(null, null); throw new NotFoundException(CardEntityName, cardId);
} }
CardDto card = await _store.GetCardAsync(cardId, ct) CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, card); return new CardResultDto(null, card);
} }
@@ -8,6 +8,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Pipeline.Application.Services; namespace Deal.Modules.Pipeline.Application.Services;
@@ -34,6 +35,9 @@ public sealed class MlReviewService(
// Размер одного чтения из очереди/отсева при объединении кандидатов. // Размер одного чтения из очереди/отсева при объединении кандидатов.
private const int MaxScan = 500; private const int MaxScan = 500;
// Имя сущности для текста ошибки «не найдено».
private const string MessageEntityName = "Исходное сообщение";
private const int TextPreviewLength = 600; private const int TextPreviewLength = 600;
/// <summary> /// <summary>
@@ -157,8 +161,9 @@ public sealed class MlReviewService(
/// <param name="dialogId">Оригинал источника (OriginRef) записи.</param> /// <param name="dialogId">Оригинал источника (OriginRef) записи.</param>
/// <param name="msgId">Внешний id записи в источнике.</param> /// <param name="msgId">Внешний id записи в источнике.</param>
/// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param> /// <param name="action">Действие: <c>skip</c> | <c>spam</c> | <c>board:&lt;id&gt;</c>.</param>
/// <returns>Результат решения; null — исходная запись не найдена (404-семантика эндпоинта).</returns> /// <returns>Результат решения.</returns>
public async Task<MlApplyResult?> ApplyAsync( /// <exception cref="NotFoundException">Исходная запись не найдена.</exception>
public async Task<MlApplyResult> ApplyAsync(
string dialogId, string dialogId,
long msgId, long msgId,
string? action, string? action,
@@ -168,17 +173,14 @@ public sealed class MlReviewService(
string dialog = (dialogId ?? string.Empty).Trim(); string dialog = (dialogId ?? string.Empty).Trim();
string externalId = msgId.ToString(CultureInfo.InvariantCulture); string externalId = msgId.ToString(CultureInfo.InvariantCulture);
SourceRef? source = await ResolveSourceAsync(dialog, externalId, ct); SourceRef source = await ResolveSourceAsync(dialog, externalId, ct)
if (source is null) ?? throw new NotFoundException(MessageEntityName, externalId);
{
return null; // 404: исходная запись не найдена
}
CardDto? card = await cardStore.GetCardBySourceAsync(source, ct); CardDto? card = await cardStore.GetCardBySourceAsync(source, ct);
string? text = await FindTextAsync(dialog, externalId, card, ct); string? text = await FindTextAsync(dialog, externalId, card, ct);
if (string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(text))
{ {
return null; // 404: исходная запись не найдена throw new NotFoundException(MessageEntityName, externalId);
} }
if (normalized == ActionSkip) if (normalized == ActionSkip)
@@ -4,6 +4,7 @@ using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Pipeline.Application.Services; namespace Deal.Modules.Pipeline.Application.Services;
@@ -48,6 +49,9 @@ public sealed class PipelineProcessingService(
private const string DuplicateSource = "dup"; private const string DuplicateSource = "dup";
// Имя сущности для текста ошибки «не найдено».
private const string RejectedEntityName = "Запись отсева";
private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal) private static readonly HashSet<string> SpamStages = new(StringComparer.Ordinal)
{ {
"spam_ml", "spam_ml",
@@ -154,17 +158,15 @@ public sealed class PipelineProcessingService(
/// </summary> /// </summary>
/// <param name="rejectedId">Id записи отсева (<c>r_...</c>).</param> /// <param name="rejectedId">Id записи отсева (<c>r_...</c>).</param>
/// <param name="reason">Причина возврата (trim, ≤500; пишется на запись для аудита).</param> /// <param name="reason">Причина возврата (trim, ≤500; пишется на запись для аудита).</param>
/// <returns>null — записи нет (404); иначе результат: Error (400) либо {id, returned:true, returnedAt}.</returns> /// <returns>Результат: Error (400) либо {id, returned:true, returnedAt}.</returns>
public async Task<RejectReturnResultDto?> ReturnAsync( /// <exception cref="NotFoundException">Запись отсева не найдена.</exception>
public async Task<RejectReturnResultDto> ReturnAsync(
string rejectedId, string rejectedId,
string reason, string reason,
CancellationToken ct) CancellationToken ct)
{ {
RejectedItemDto? row = await store.GetAsync(rejectedId, ct); RejectedItemDto row = await store.GetAsync(rejectedId, ct)
if (row is null) ?? throw new NotFoundException(RejectedEntityName, rejectedId);
{
return null;
}
if (row.Returned) if (row.Returned)
{ {
@@ -1,6 +1,7 @@
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel; using Deal.SharedKernel;
using Deal.SharedKernel.Errors;
using Deal.SharedKernel.Utilities; using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Tenants.Application.Services; namespace Deal.Modules.Tenants.Application.Services;
@@ -17,6 +18,9 @@ public sealed class TenantAdminService(
// Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator). // Случайные байты одноразового пароля владельца: 12 → ровно 16 символов Base64Url (как InviteCodeGenerator).
private const int InitialPasswordRandomByteCount = 12; private const int InitialPasswordRandomByteCount = 12;
// Имя сущности для текста ошибки «не найдено» (пользователь = тенант с его окружением).
private const string TenantEntityName = "Пользователь";
/// <summary> /// <summary>
/// Создаёт тенанта оператором /// Создаёт тенанта оператором
/// </summary> /// </summary>
@@ -103,14 +107,12 @@ public sealed class TenantAdminService(
/// Детали тенанта с пользователями /// Детали тенанта с пользователями
/// </summary> /// </summary>
/// <param name="id">Идентификатор тенанта.</param> /// <param name="id">Идентификатор тенанта.</param>
/// <returns>Детали и пользователи тенанта (по CreatedAt) или null, если тенанта нет.</returns> /// <returns>Детали и пользователи тенанта (по CreatedAt).</returns>
public async Task<TenantDetailDto?> GetAsync(Guid id, CancellationToken ct) /// <exception cref="NotFoundException">Тенант не найден.</exception>
public async Task<TenantDetailDto> GetAsync(Guid id, CancellationToken ct)
{ {
var tenant = await tenantRepository.FindByIdAsync(id, ct); var tenant = await tenantRepository.FindByIdAsync(id, ct)
if (tenant is null) ?? throw new NotFoundException(TenantEntityName, id.ToString("N"));
{
return null;
}
IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(id, ct); IReadOnlyList<UserIdentityDto> users = await authStore.ListUsersByTenantIdAsync(id, ct);
return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users); return new TenantDetailDto(tenant.Id, tenant.Name, tenant.Status, tenant.CreatedAt, users);
@@ -67,21 +67,22 @@ public sealed class TgStatusServiceTests
/// Готовый аккаунт /// Готовый аккаунт
/// </summary> /// </summary>
[Fact] [Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() public async Task GetAsync_ReadyGateway_PrefersLiveAccountOverKvAndComputesConnected()
{ {
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); (TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\""); settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
// Транспортный Connected=false, но phase ready → UI должен считать аккаунт подключённым.
gateway.Status = new TelegramAccountStatusDto( gateway.Status = new TelegramAccountStatusDto(
Phase: "ready", Connected: true, Listener: true, Account: "gateway-account", Error: null, QrUrl: null); Phase: "ready", Connected: false, Listener: true, Account: "@liveuser", Error: null, QrUrl: null);
TgStatusDto status = await service.GetAsync(CancellationToken.None); TgStatusDto status = await service.GetAsync(CancellationToken.None);
Assert.Equal("ready", status.Phase); Assert.Equal("ready", status.Phase);
Assert.True(status.Connected); Assert.True(status.Connected); // connected = авторизация (phase ready), не транспорт
Assert.True(status.Listener); Assert.True(status.Listener);
Assert.Equal("@realuser", status.Account); // KV tgAccount перекрывает справочное поле гейта (Ruling 8) Assert.Equal("@liveuser", status.Account); // живой account приоритетнее KV
Assert.Equal(1, status.Monitored); Assert.Equal(1, status.Monitored);
Assert.True(status.KeysSet); Assert.True(status.KeysSet);
} }
@@ -53,13 +53,12 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task GetCard_Missing_ReturnsNull() public async Task GetCard_Missing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
CardDto? card = await service.GetCardAsync("l_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetCardAsync("l_missing", CancellationToken.None));
Assert.Null(card);
} }
@@ -146,15 +145,14 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task Move_CardMissing_ReturnsNullLead() public async Task Move_CardMissing_ThrowsNotFound()
{ {
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create(); (CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py")); store.SeedBoard(Board("b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(store.Moves); Assert.Empty(store.Moves);
Assert.Empty(ml.Pushed); Assert.Empty(ml.Pushed);
} }
@@ -3,10 +3,11 @@ using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Infrastructure; namespace Deal.Tests.Unit.Infrastructure;
@@ -32,7 +33,6 @@ public sealed class CardMoverTests
CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None);
Assert.Null(result.Error); Assert.Null(result.Error);
Assert.True(result.Exists);
CardDto card = Assert.Single(store.CardDtos); CardDto card = Assert.Single(store.CardDtos);
Assert.Equal(CardsDefaultContainers.Planned, card.Col); Assert.Equal(CardsDefaultContainers.Planned, card.Col);
Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание
@@ -50,7 +50,6 @@ public sealed class CardMoverTests
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None); CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None);
Assert.Null(result.Error); Assert.Null(result.Error);
Assert.True(result.Exists);
Assert.Equal("b_py", Assert.Single(store.CardDtos).Col); Assert.Equal("b_py", Assert.Single(store.CardDtos).Col);
Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса
} }
@@ -64,18 +63,15 @@ public sealed class CardMoverTests
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None); CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error);
Assert.True(result.Exists); // ошибка важнее признака наличия
} }
[Fact] [Fact]
public async Task Move_ToStage_CardMissing_ReportsNotFound() public async Task Move_ToStage_CardMissing_ThrowsNotFound()
{ {
(CardMover mover, _) = Create(); (CardMover mover, _) = Create();
CardMoveResultDto result = await mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None));
Assert.Null(result.Error);
Assert.False(result.Exists);
} }
private static (CardMover Mover, TestKanjStore Store) Create() private static (CardMover Mover, TestKanjStore Store) Create()
@@ -1,7 +1,8 @@
using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Discovery; namespace Deal.Tests.Unit.Modules.Discovery;
@@ -114,14 +115,13 @@ public sealed class DiscoveryCandidatesServiceTests
} }
[Fact] [Fact]
public async Task Add_MissingTask_ReturnsNullWithoutLog() public async Task Add_MissingTask_ThrowsNotFoundWithoutLog()
{ {
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
DiscoveryCandidateDto? candidate = await service.AddAsync( await Assert.ThrowsAsync<NotFoundException>(() => service.AddAsync(
"dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None); "dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None));
Assert.Null(candidate);
Assert.Empty(store.Candidates); Assert.Empty(store.Candidates);
Assert.Empty(store.Log); Assert.Empty(store.Log);
} }
@@ -175,13 +175,12 @@ public sealed class DiscoveryCandidatesServiceTests
} }
[Fact] [Fact]
public async Task MarkJoined_MissingCandidate_ReturnsNull() public async Task MarkJoined_MissingCandidate_ThrowsNotFound()
{ {
(DiscoveryCandidatesService service, _) = Create(); (DiscoveryCandidatesService service, _) = Create();
DiscoveryCandidateDto? candidate = await service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None));
Assert.Null(candidate);
} }
[Fact] [Fact]
@@ -238,14 +237,12 @@ public sealed class DiscoveryCandidatesServiceTests
} }
[Fact] [Fact]
public async Task MarkRejected_MissingCandidate_ReturnsNull() public async Task MarkRejected_MissingCandidate_ThrowsNotFound()
{ {
(DiscoveryCandidatesService service, _) = Create(); (DiscoveryCandidatesService service, _) = Create();
DiscoveryCandidateDto? candidate = await service.MarkRejectedAsync( await Assert.ThrowsAsync<NotFoundException>(
"-1001", "причина", CancellationToken.None); () => service.MarkRejectedAsync("-1001", "причина", CancellationToken.None));
Assert.Null(candidate);
} }
[Fact] [Fact]
@@ -315,17 +312,15 @@ public sealed class DiscoveryCandidatesServiceTests
} }
[Fact] [Fact]
public async Task Set_WrongTask_ReturnsNull() public async Task Set_WrongTask_ThrowsNotFound()
{ {
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedTask(Task("dt_2")); store.SeedTask(Task("dt_2"));
store.SeedCandidate(Candidate("-1001", "dt_1")); store.SeedCandidate(Candidate("-1001", "dt_1"));
DiscoveryCandidateDto? candidate = await service.SetAsync( await Assert.ThrowsAsync<NotFoundException>(() => service.SetAsync(
"dt_2", "-1001", new DiscoveryCandidatePatch { Participants = 10 }, CancellationToken.None); "dt_2", "-1001", new DiscoveryCandidatePatch { Participants = 10 }, CancellationToken.None));
Assert.Null(candidate); // кандидат другой задачи (python L465466)
} }
[Fact] [Fact]
@@ -1,9 +1,10 @@
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Kanban; namespace Deal.Tests.Unit.Modules.Kanban;
@@ -47,14 +48,12 @@ public sealed class CardsServiceRemindersTests
} }
[Fact] [Fact]
public async Task Set_MissingCard_Returns404EvenWhenDisabled() public async Task Set_MissingCard_ThrowsNotFoundEvenWhenDisabled()
{ {
(CardsService service, _, _) = Create(remindersEnabled: false); (CardsService service, _, _) = Create(remindersEnabled: false);
CardResultDto result = await service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
} }
[Fact] [Fact]
@@ -73,25 +72,23 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Clear_WithReminder_ClearsItAndReturnsTrue() public async Task Clear_WithReminder_ClearsIt()
{ {
(CardsService service, TestKanjStore store, _) = Create(); (CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); await service.ClearReminderAsync("c_1", CancellationToken.None);
Assert.True(cleared);
Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен
} }
[Fact] [Fact]
public async Task Clear_MissingCard_ReturnsFalse() public async Task Clear_MissingCard_ThrowsNotFound()
{ {
(CardsService service, _, _) = Create(); (CardsService service, _, _) = Create();
bool cleared = await service.ClearReminderAsync("c_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.ClearReminderAsync("c_missing", CancellationToken.None));
Assert.False(cleared); // эндпоинт отвечает 404 «Карточка не найдена»
} }
[Fact] [Fact]
@@ -100,9 +97,8 @@ public sealed class CardsServiceRemindersTests
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false); (CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); await service.ClearReminderAsync("c_1", CancellationToken.None);
Assert.True(cleared);
Assert.Null(Assert.Single(store.CardDtos).Reminder); Assert.Null(Assert.Single(store.CardDtos).Reminder);
} }
@@ -114,22 +110,20 @@ public sealed class CardsServiceRemindersTests
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
long beforeMs = NowMs(); long beforeMs = NowMs();
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); await service.SnoozeReminderAsync("c_1", CancellationToken.None);
Assert.True(snoozed);
long afterMs = NowMs(); long afterMs = NowMs();
CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!; CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!;
Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч
} }
[Fact] [Fact]
public async Task Snooze_MissingCard_ReturnsFalse() public async Task Snooze_MissingCard_ThrowsNotFound()
{ {
(CardsService service, _, _) = Create(); (CardsService service, _, _) = Create();
bool snoozed = await service.SnoozeReminderAsync("c_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.SnoozeReminderAsync("c_missing", CancellationToken.None));
Assert.False(snoozed); // эндпоинт отвечает 404 «Карточка не найдена»
} }
[Fact] [Fact]
@@ -138,9 +132,8 @@ public sealed class CardsServiceRemindersTests
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false); (CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); await service.SnoozeReminderAsync("c_1", CancellationToken.None);
Assert.True(snoozed);
Assert.NotNull(Assert.Single(store.CardDtos).Reminder); Assert.NotNull(Assert.Single(store.CardDtos).Reminder);
} }
@@ -4,6 +4,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
@@ -241,12 +242,11 @@ public sealed class MlReviewServiceTests
} }
[Fact] [Fact]
public async Task Apply_MessageNotFound_ReturnsNull() public async Task Apply_MessageNotFound_ThrowsNotFound()
{ {
MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _); MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None));
Assert.Null(result);
} }
} }
@@ -2,6 +2,7 @@ using System.Globalization;
using Deal.Modules.Cards.Application.Sources; using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
@@ -165,13 +166,13 @@ public sealed class PipelineProcessingServiceTests
[Fact] [Fact]
public async Task ReturnAsync_RecordNotFound_ReturnsNull() public async Task ReturnAsync_RecordNotFound_ThrowsNotFound()
{ {
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create(); (PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.ReturnAsync("r_missing", string.Empty, CancellationToken.None));
Assert.Null(result); // эндпоинт ответит 404 «Запись не найдена» (Ruling 10)
Assert.Empty(store.Queue); Assert.Empty(store.Queue);
Assert.Empty(ml.Pushed); Assert.Empty(ml.Pushed);
} }
@@ -1,7 +1,8 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services; using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Support; using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Tenants; namespace Deal.Tests.Unit.Modules.Tenants;
@@ -148,11 +149,12 @@ public sealed class TenantAdminServiceTests
} }
[Fact] [Fact]
public async Task GetAsync_ForUnknownTenant_ReturnsNull() public async Task GetAsync_ForUnknownTenant_ThrowsNotFound()
{ {
var service = NewService(new TestTenantStore(), new TestAuthStore()); var service = NewService(new TestTenantStore(), new TestAuthStore());
Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None)); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetAsync(Guid.NewGuid(), CancellationToken.None));
} }
[Fact] [Fact]
@@ -40,13 +40,12 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task Get_Missing_ReturnsNull() public async Task Get_Missing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
CardDto? card = await service.GetCardAsync("c_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetCardAsync("c_missing", CancellationToken.None));
Assert.Null(card);
} }
@@ -319,14 +318,12 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task Move_CardMissing_ReturnsNullCard() public async Task Move_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
CardResultDto result = await service.MoveStageCardAsync("c_missing", "work", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.MoveStageCardAsync("c_missing", "work", CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // 404 «Карточка не найдена» — текст у эндпоинта
} }
@@ -465,14 +462,13 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation() public async Task AddLink_CardMissing_ThrowsBeforeUrlValidation()
{ {
(CardsService service, TestKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // 404-семантика: карточки нет раньше валидации url
Assert.Empty(store.CardDtos); Assert.Empty(store.CardDtos);
} }
@@ -514,14 +510,13 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task RemoveLink_CardMissing_ReturnsNullCard() public async Task RemoveLink_CardMissing_ThrowsNotFound()
{ {
(CardsService service, TestKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(store.CardDtos); Assert.Empty(store.CardDtos);
} }
+14
View File
@@ -0,0 +1,14 @@
# Фронтенд Deal: сборка SPA (Vite) и отдача статики через Caddy.
# Контекст сборки — корень репозитория (см. deploy/compose.dev.yml: build.context ..).
# /api/* проксируется на core:5080 (см. deploy/caddy/Caddyfile.dev).
FROM node:24-alpine AS build
WORKDIR /app
COPY src/frontend/package.json src/frontend/package-lock.json ./
RUN npm ci
COPY src/frontend/ ./
RUN npm run build
FROM caddy:2.9.1
COPY --from=build /app/dist /srv
COPY deploy/caddy/Caddyfile.dev /etc/caddy/Caddyfile
@@ -2,10 +2,9 @@
// Вкладка «Telegram» настроек: подключение аккаунта (QR / номер / код / // Вкладка «Telegram» настроек: подключение аккаунта (QR / номер / код /
// облачный пароль) и авто-мониторинг новых чатов. Ключи приложения // облачный пароль) и авто-мониторинг новых чатов. Ключи приложения
// (api_id/api_hash) задаёт оператор глобально — у тенанта их нет. // (api_id/api_hash) задаёт оператор глобально — у тенанта их нет.
// Опрос статуса при QR-входе живёт в SettingsView (watch на state.tgState), // Пока идёт QR-вход, вкладка сама опрашивает статус и обновляет картинку.
// чтобы поведение при переключении вкладок осталось прежним. import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ref } from 'vue' import { state, connectTgStep, tgStart, disconnectTg, refreshTgStatus } from '../../store.js'
import { state, connectTgStep, tgStart, disconnectTg } from '../../store.js'
import Icon from '../Icon.vue' import Icon from '../Icon.vue'
import UiToggle from '../ui/ToggleSwitch.vue' import UiToggle from '../ui/ToggleSwitch.vue'
@@ -14,6 +13,58 @@ const code = ref('')
const tgPass = ref('') const tgPass = ref('')
const qrTick = ref(0) const qrTick = ref(0)
// QR-ссылка приходит после старта: перезагружаем картинку, когда она появилась/сменилась.
watch(
() => state.tgQrUrl,
() => {
if (state.tgState === 'qr') qrTick.value++
},
)
// Таймер QR: пока идёт вход — периодически обновляем статус и саму картинку
// (токен Telegram меняется); при подключении показываем учётку и останавливаемся.
const QR_POLL_MS = 5000
let qrTimer = null
function stopQrTimer() {
if (qrTimer) {
clearInterval(qrTimer)
qrTimer = null
}
}
function startQrTimer() {
stopQrTimer()
qrTimer = setInterval(async () => {
await refreshTgStatus()
// Продолжаем опрос, пока не авторизованы: переходной статус (phase ready до
// подтверждения транспорта) не должен останавливать обновление.
if (state.tgConnected) {
stopQrTimer()
return
}
if (state.tgState === 'idle') {
stopQrTimer()
return
}
qrTick.value++
}, QR_POLL_MS)
}
// Таймер стартует при входе в режим QR и сам останавливается при подключении/сбросе.
watch(
() => state.tgState,
(s) => {
if (s === 'qr') startQrTimer()
},
)
onMounted(async () => {
await refreshTgStatus()
if (state.tgState === 'qr') startQrTimer()
})
onBeforeUnmount(stopQrTimer)
async function submitCode() { async function submitCode() {
await connectTgStep(code.value) await connectTgStep(code.value)
if (state.tgState === 'done') code.value = '' if (state.tgState === 'done') code.value = ''
+6 -3
View File
@@ -5,11 +5,12 @@ import { state, toast, errMsg, fmtMsgTime } from './core.js'
export function mapTgStatus(st) { export function mapTgStatus(st) {
if (!st) return if (!st) return
state.tgConnected = !!st.connected const phase = st.phase || 'idle'
// «Подключён» = авторизован (phase ready); транспортный connected ненадёжен для UI.
state.tgConnected = phase === 'ready' || !!st.connected
state.tgAccount = st.account || '' state.tgAccount = st.account || ''
state.tgKeysSet = !!st.keysSet state.tgKeysSet = !!st.keysSet
if (st.error) state.tgError = st.error if (st.error) state.tgError = st.error
const phase = st.phase || 'idle'
if (phase === 'ready') { if (phase === 'ready') {
state.tgState = 'done' state.tgState = 'done'
} else if (['phone', 'code', 'password', 'qr'].includes(phase)) { } else if (['phone', 'code', 'password', 'qr'].includes(phase)) {
@@ -147,10 +148,12 @@ export async function refreshTgStatus() {
export async function tgStart() { export async function tgStart() {
state.tgError = '' state.tgError = ''
if (state.tgQrMode) { if (state.tgQrMode) {
state.tgState = 'qr'
try { try {
// Сначала стартуем QR на бэке, затем показываем картинку: иначе <img> успевает запросить
// /api/tg/qr-image до готовности QR и остаётся пустым.
const r = await api.post('/api/tg/start-qr') const r = await api.post('/api/tg/start-qr')
state.tgQrUrl = r.qrUrl || '' state.tgQrUrl = r.qrUrl || ''
state.tgState = 'qr'
toast(t('settings.ssylka-dlya-vhoda-sgenerirovana-otkrojte'), { icon: 'send' }) toast(t('settings.ssylka-dlya-vhoda-sgenerirovana-otkrojte'), { icon: 'send' })
} catch (e) { } catch (e) {
state.tgError = errMsg(e) state.tgError = errMsg(e)
+1 -28
View File
@@ -1,7 +1,6 @@
<script setup> <script setup>
import { t } from '@/i18n/index.js' import { t } from '@/i18n/index.js'
import { onBeforeUnmount, watch } from 'vue' import { state } from '../store.js'
import { state, refreshTgStatus } from '../store.js'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import TelegramTab from '../components/settings/TelegramTab.vue' import TelegramTab from '../components/settings/TelegramTab.vue'
import AiTab from '../components/settings/AiTab.vue' import AiTab from '../components/settings/AiTab.vue'
@@ -26,32 +25,6 @@ const TABS = [
{ id: 'appearance', name: t('settings.vneshnij-vid'), icon: 'palette' }, { id: 'appearance', name: t('settings.vneshnij-vid'), icon: 'palette' },
{ id: 'profile', name: t('settings.profil'), icon: 'key' }, { id: 'profile', name: t('settings.profil'), icon: 'key' },
] ]
// пока идёт QR-вход (вкладка Telegram) — опрашиваем статус, чтобы поймать
// момент подтверждения. Watch живёт на уровне экрана настроек: при
// переключении вкладок опрос не прерывается (как в исходном SettingsView).
let qrTimer = null
watch(
() => state.tgState,
(s) => {
if (s === 'qr') {
stopQrPoll()
qrTimer = setInterval(async () => {
await refreshTgStatus()
if (state.tgState === 'done') stopQrPoll()
}, 4000)
} else {
stopQrPoll()
}
},
)
function stopQrPoll() {
if (qrTimer) {
clearInterval(qrTimer)
qrTimer = null
}
}
onBeforeUnmount(stopQrPoll)
</script> </script>
<template> <template>