using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tnsu.ApprovalRoute.Infrastructure.SharePoint; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; /// /// POST {BaseUrl}/Webhook/Subscribe?listName=…&url=…&notificationUrl=… — просит /// портал завести подписку SharePoint на только что созданный список данных. /// /// Параметры уходят строкой запроса, тела у запроса нет — так описана ручка. /// /// Отдельный клиент, а не метод в ITnsuPortalClient: тот про справочник сотрудников /// и обёрнут кешем (CachingTnsuPortalClient), а здесь команда, которую кешировать /// нельзя ни в каком виде. Авторизация общая с ним — см. TnsuPortalAuthorizer. /// /// Адрес приёмника уведомлений живёт в SharePoint:NotificationUrl — в одном блоке с /// остальными настройками SharePoint, а не в своём: подписка относится к спискам, /// а не к порталу как таковому. /// /// Ошибки НЕ пробрасываются наружу. Список к этому моменту уже создан, и уронить /// провижининг из-за недоступности портала значило бы оставить партиционирование /// нерабочим целиком (задача Hangfire ушла бы в повторы, а порог по элементам /// продолжал бы срабатывать). Поэтому сбой — Error в лог с полным контекстом /// (имя списка и сайт видны, подписку можно завести руками), а провижининг идёт /// дальше. Отдельно предупреждаем про 401/403: в фоновой задаче нет входящего /// токена, а client_credentials требует заполненных TnsuPortal:ClientSecret/Scope /// (в конфигурации они пустые — см. TnsuPortalAuthorizer). /// public sealed class HttpSharePointWebhookSubscriber( HttpClient httpClient, TnsuPortalAuthorizer authorizer, IOptions options, ILogger logger) : ISharePointWebhookSubscriber { public async Task TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken) { var notificationUrl = options.Value.NotificationUrl; if (string.IsNullOrWhiteSpace(notificationUrl)) { // Без адреса приёмника подписка бессмысленна — он же служит выключателем // (см. SharePointOptions.NotificationUrl). logger.LogInformation( "SharePoint:NotificationUrl не задан — список «{ListTitle}» на уведомления не подписан.", listTitle); return false; } if (string.IsNullOrWhiteSpace(listTitle)) { // Без имени списка ручке нечего подписывать: у неё нет другого способа // его найти (адрес списка она не принимает). logger.LogError("Заголовок нового списка пуст — подписка на уведомления невозможна."); return false; } // Полный адрес сайта целиком («https://tnsukz.sharepoint.com/sites/requests»), // а не короткое имя — именно это ждёт ручка. var site = siteUrl.TrimEnd('/'); var url = $"/Webhook/Subscribe?listName={Uri.EscapeDataString(listTitle)}" + $"&url={Uri.EscapeDataString(site)}" + $"¬ificationUrl={Uri.EscapeDataString(notificationUrl)}"; try { using var request = new HttpRequestMessage(HttpMethod.Post, url); await authorizer.AuthorizeAsync(request, cancellationToken); using var response = await httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { // Тело читаем именно на неуспехе — в нём причина отказа портала. var body = await response.Content.ReadAsStringAsync(cancellationToken); logger.LogError( "Портал отказал в подписке списка «{ListTitle}» (сайт {Site}): {StatusCode}. Ответ: {Body}", listTitle, site, (int)response.StatusCode, body); return false; } logger.LogInformation( "Список «{ListTitle}» (сайт {Site}) подписан на уведомления SharePoint.", listTitle, site); return true; } catch (Exception ex) { logger.LogError( ex, "Не удалось подписать список «{ListTitle}» (сайт {Site}) на уведомления — подпишите вручную.", listTitle, site); return false; } } }