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.
This commit is contained in:
parent
256a07f68c
commit
ef42797b31
|
|
@ -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<INotificationService, WebhookNotificationService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// =========================================
|
||||
|
|
|
|||
|
|
@ -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<InvoiceDetailDto> 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<InvoiceDetailResponse>($"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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
namespace DoliMiddlewareApi.Services.Notifications;
|
||||
|
||||
public interface INotificationService
|
||||
{
|
||||
Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus);
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DoliMiddlewareApi.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// </summary>
|
||||
public class WebhookNotificationService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<WebhookNotificationService> logger) : INotificationService
|
||||
{
|
||||
private static readonly Dictionary<string, (string label, string color)> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -15,5 +15,7 @@
|
|||
"Secret": "mi-super-secreto-jwt-aqui-cambiar-en-produccion",
|
||||
"Issuer": "DoliMiddleware",
|
||||
"Audience": "DoliClients"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Notifications":{
|
||||
"WebhookUrl": ""
|
||||
}}
|
||||
|
|
|
|||
Loading…
Reference in New Issue