From 256a07f68c3ff39f963f028595a7aaabe034f005 Mon Sep 17 00:00:00 2001 From: JavMB Date: Thu, 14 May 2026 20:48:35 +0200 Subject: [PATCH 1/3] Add auth and response docs to DocumentController endpoints --- DoliMiddlewareApi/Controllers/DocumentController.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/DoliMiddlewareApi/Controllers/DocumentController.cs b/DoliMiddlewareApi/Controllers/DocumentController.cs index 575fced..567993b 100644 --- a/DoliMiddlewareApi/Controllers/DocumentController.cs +++ b/DoliMiddlewareApi/Controllers/DocumentController.cs @@ -1,4 +1,5 @@ using DoliMiddlewareApi.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -6,10 +7,14 @@ namespace DoliMiddlewareApi.Controllers { [Route("api/[controller]")] [ApiController] + [Authorize] public class DocumentController(DocumentService documentService) : ControllerBase { [HttpGet("invoice/{invoiceRef}/pdf")] + [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetInvoicePdf(string invoiceRef) { var (content, filename) = await documentService.BuildInvoicePdfAsync(invoiceRef); From ef42797b317989d6d15afdb8dd88051cd0bbb26f Mon Sep 17 00:00:00 2001 From: javiermengual Date: Thu, 14 May 2026 22:59:16 +0200 Subject: [PATCH 2/3] feat: add webhook notifications for invoice status changes Implements INotificationService with Teams/Slack-compatible WebhookNotificationService. Fires on ValidateInvoice and ChangeInvoiceStatus. Silently skipped if WebhookUrl not configured. --- DoliMiddlewareApi/Program.cs | 12 +- DoliMiddlewareApi/Services/InvoiceService.cs | 13 +- .../Notifications/INotificationService.cs | 6 + .../WebhookNotificationService.cs | 126 ++++++++++++++++++ DoliMiddlewareApi/appsettings.json | 6 +- 5 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 DoliMiddlewareApi/Services/Notifications/INotificationService.cs create mode 100644 DoliMiddlewareApi/Services/Notifications/WebhookNotificationService.cs diff --git a/DoliMiddlewareApi/Program.cs b/DoliMiddlewareApi/Program.cs index ba25023..b852be3 100644 --- a/DoliMiddlewareApi/Program.cs +++ b/DoliMiddlewareApi/Program.cs @@ -3,6 +3,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; @@ -46,7 +47,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }); builder.Services.AddAuthorization(); - + // CORS Configuration builder.Services.AddCors(options => { @@ -123,6 +124,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 083a06f..65e20d6 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 693725c..f33ae09 100644 --- a/DoliMiddlewareApi/appsettings.json +++ b/DoliMiddlewareApi/appsettings.json @@ -15,5 +15,7 @@ "Secret": "mi-super-secreto-jwt-aqui-cambiar-en-produccion", "Issuer": "DoliMiddleware", "Audience": "DoliClients" - } -} \ No newline at end of file + }, + "Notifications":{ + "WebhookUrl": "" +}} From f5ff72434177aa84ab803ff05d1b4f21550bc7ee Mon Sep 17 00:00:00 2001 From: javiermengual Date: Thu, 14 May 2026 23:00:34 +0200 Subject: [PATCH 3/3] chore: ignore appsettings.Development.json to prevent secrets leak --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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