diff --git a/.gitignore b/.gitignore index 4d162e2..6e6441c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ obj/ /packages/ out/ +## Secrets +appsettings.Development.json + ## IDE - Rider / IntelliJ .idea/ *.iml diff --git a/DoliMiddlewareApi/Program.cs b/DoliMiddlewareApi/Program.cs index d9d983d..d9f75b5 100644 --- a/DoliMiddlewareApi/Program.cs +++ b/DoliMiddlewareApi/Program.cs @@ -4,6 +4,7 @@ using DoliMiddlewareApi.Exceptions; using DoliMiddlewareApi.Services; using DoliMiddlewareApi.Services.Auth; using DoliMiddlewareApi.Services.Clients; +using DoliMiddlewareApi.Services.Notifications; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics; using Microsoft.Extensions.Caching.Memory; @@ -193,6 +194,15 @@ builder.Services.AddMemoryCache(); // HttpContext accessor para acceder a User.Claims en servicios builder.Services.AddHttpContextAccessor(); +builder.Services.AddHttpClient("Webhook"); + +// ========================================= +// NOTIFICACIONES — Webhook (Teams / Slack) +// Configura Notifications:WebhookUrl en appsettings o variable de entorno +// Si no está configurado, las notificaciones se ignoran silenciosamente +// ========================================= +builder.Services.AddScoped(); + var app = builder.Build(); // ========================================= diff --git a/DoliMiddlewareApi/Services/InvoiceService.cs b/DoliMiddlewareApi/Services/InvoiceService.cs index 673689d..417741a 100644 --- a/DoliMiddlewareApi/Services/InvoiceService.cs +++ b/DoliMiddlewareApi/Services/InvoiceService.cs @@ -7,10 +7,11 @@ using DoliMiddlewareApi.Dtos.query; using DoliMiddlewareApi.Exceptions; using DoliMiddlewareApi.Mappers; using DoliMiddlewareApi.Services.Clients; +using DoliMiddlewareApi.Services.Notifications; namespace DoliMiddlewareApi.Services; -public class InvoiceService(IDolibarrApiClient apiClient) +public class InvoiceService(IDolibarrApiClient apiClient, INotificationService notifications) { public async Task GetInvoiceAsync(int id) { @@ -121,13 +122,17 @@ public class InvoiceService(IDolibarrApiClient apiClient) var normalized = status.Trim().ToLowerInvariant(); var endpoint = normalized switch { - "draft" => $"invoices/{id}/settodraft", + "draft" => $"invoices/{id}/settodraft", "unpaid" => $"invoices/{id}/settounpaid", - "paid" => $"invoices/{id}/settopaid", + "paid" => $"invoices/{id}/settopaid", _ => throw new ValidationException("Estado invalido. Usa: draft, unpaid, paid.") }; await apiClient.PostAsync(endpoint, new { }); + + // Notificación a Teams/Slack — fire and forget desde el punto de vista del caller + var invoice = await apiClient.GetResourceAsync($"invoices/{id}"); + await notifications.NotifyInvoiceStatusChangedAsync(id, invoice.@ref ?? $"#{id}", normalized); } public async Task ValidateInvoiceAsync(int id) @@ -136,6 +141,8 @@ public class InvoiceService(IDolibarrApiClient apiClient) if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden validar facturas en borrador (draft)"); await apiClient.PostAsync($"invoices/{id}/validate", new { }); + + await notifications.NotifyInvoiceStatusChangedAsync(id, invoice.@ref ?? $"#{id}", "unpaid"); } public async Task DeleteInvoiceAsync(int id) diff --git a/DoliMiddlewareApi/Services/Notifications/INotificationService.cs b/DoliMiddlewareApi/Services/Notifications/INotificationService.cs new file mode 100644 index 0000000..e17e344 --- /dev/null +++ b/DoliMiddlewareApi/Services/Notifications/INotificationService.cs @@ -0,0 +1,6 @@ +namespace DoliMiddlewareApi.Services.Notifications; + +public interface INotificationService +{ + Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus); +} diff --git a/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs b/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs new file mode 100644 index 0000000..8e98ac0 --- /dev/null +++ b/DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs @@ -0,0 +1,126 @@ +using System.Text; +using System.Text.Json; + +namespace DoliMiddlewareApi.Services.Notifications; + +/// +/// Envía notificaciones a un webhook de Teams o Slack cuando cambia el estado de una factura. +/// Compatible con Teams (Adaptive Cards) y Slack (Block Kit). +/// Configurar la URL en appsettings: Notifications:WebhookUrl +/// +public class WebhookNotificationService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger) : INotificationService +{ + private static readonly Dictionary StatusMeta = new() + { + ["draft"] = ("Borrador", "#8a7dff"), + ["unpaid"] = ("Pendiente de pago", "#ffb454"), + ["paid"] = ("Pagada ✓", "#5dd39e"), + }; + + public async Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus) + { + var webhookUrl = configuration["Notifications:WebhookUrl"]; + if (string.IsNullOrWhiteSpace(webhookUrl)) + return; // Notificaciones desactivadas — no hay webhook configurado + + var (label, color) = StatusMeta.GetValueOrDefault(newStatus, (newStatus, "#888")); + + var payload = BuildPayload(invoiceId, invoiceRef, label, color); + + try + { + var client = httpClientFactory.CreateClient("Webhook"); + var content = new StringContent( + JsonSerializer.Serialize(payload), + Encoding.UTF8, + "application/json"); + + var response = await client.PostAsync(webhookUrl, content); + + if (!response.IsSuccessStatusCode) + logger.LogWarning( + "Webhook respondió {Status} para factura {InvoiceId}", + response.StatusCode, invoiceId); + } + catch (Exception ex) + { + // Nunca rompemos el flujo principal por un fallo en notificaciones + logger.LogError(ex, + "Error enviando notificación para factura {InvoiceId}", invoiceId); + } + } + + /// + /// Construye un payload compatible con Teams (Adaptive Card via Incoming Webhook) + /// y con Slack (message payload básico con attachments). + /// Teams espera { "type": "message", "attachments": [...] } con Adaptive Card. + /// Slack espera { "text": "...", "attachments": [...] }. + /// Detectamos por URL cuál usar. + /// + private object BuildPayload(int invoiceId, string invoiceRef, string label, string color) + { + var webhookUrl = configuration["Notifications:WebhookUrl"] ?? ""; + + if (webhookUrl.Contains("slack.com")) + { + return new + { + text = $"🧾 Factura *{invoiceRef}* ha cambiado de estado", + attachments = new[] + { + new + { + color, + fields = new[] + { + new { title = "Factura", value = invoiceRef, @short = true }, + new { title = "Estado", value = label, @short = true }, + } + } + } + }; + } + + // Teams — Adaptive Card + return new + { + type = "message", + attachments = new[] + { + new + { + contentType = "application/vnd.microsoft.card.adaptive", + content = new + { + type = "AdaptiveCard", + version = "1.4", + body = new object[] + { + new + { + type = "TextBlock", + text = "🧾 Estado de factura actualizado", + weight = "Bolder", + size = "Medium" + }, + new + { + type = "FactSet", + facts = new[] + { + new { title = "Factura", value = invoiceRef }, + new { title = "ID", value = invoiceId.ToString() }, + new { title = "Estado", value = label }, + } + } + }, + msteams = new { width = "Full" } + } + } + } + }; + } +} diff --git a/DoliMiddlewareApi/appsettings.json b/DoliMiddlewareApi/appsettings.json index aa06801..e9f936b 100644 --- a/DoliMiddlewareApi/appsettings.json +++ b/DoliMiddlewareApi/appsettings.json @@ -18,5 +18,7 @@ "Secret": "", "Issuer": "DoliMiddleware", "Audience": "DoliClients" - } -} \ No newline at end of file + }, + "Notifications":{ + "WebhookUrl": "" +}}