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;
|
||||||
using DoliMiddlewareApi.Services.Auth;
|
using DoliMiddlewareApi.Services.Auth;
|
||||||
using DoliMiddlewareApi.Services.Clients;
|
using DoliMiddlewareApi.Services.Clients;
|
||||||
|
using DoliMiddlewareApi.Services.Notifications;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Diagnostics;
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
@ -123,6 +124,15 @@ builder.Services.AddMemoryCache();
|
||||||
// HttpContext accessor para acceder a User.Claims en servicios
|
// HttpContext accessor para acceder a User.Claims en servicios
|
||||||
builder.Services.AddHttpContextAccessor();
|
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();
|
var app = builder.Build();
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,11 @@ using DoliMiddlewareApi.Dtos.query;
|
||||||
using DoliMiddlewareApi.Exceptions;
|
using DoliMiddlewareApi.Exceptions;
|
||||||
using DoliMiddlewareApi.Mappers;
|
using DoliMiddlewareApi.Mappers;
|
||||||
using DoliMiddlewareApi.Services.Clients;
|
using DoliMiddlewareApi.Services.Clients;
|
||||||
|
using DoliMiddlewareApi.Services.Notifications;
|
||||||
|
|
||||||
namespace DoliMiddlewareApi.Services;
|
namespace DoliMiddlewareApi.Services;
|
||||||
|
|
||||||
public class InvoiceService(IDolibarrApiClient apiClient)
|
public class InvoiceService(IDolibarrApiClient apiClient, INotificationService notifications)
|
||||||
{
|
{
|
||||||
public async Task<InvoiceDetailDto> GetInvoiceAsync(int id)
|
public async Task<InvoiceDetailDto> GetInvoiceAsync(int id)
|
||||||
{
|
{
|
||||||
|
|
@ -128,6 +129,10 @@ public class InvoiceService(IDolibarrApiClient apiClient)
|
||||||
};
|
};
|
||||||
|
|
||||||
await apiClient.PostAsync(endpoint, new { });
|
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)
|
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)");
|
if (invoice.statut != "0") throw new ForbiddenException("Solo se pueden validar facturas en borrador (draft)");
|
||||||
|
|
||||||
await apiClient.PostAsync($"invoices/{id}/validate", new { });
|
await apiClient.PostAsync($"invoices/{id}/validate", new { });
|
||||||
|
|
||||||
|
await notifications.NotifyInvoiceStatusChangedAsync(id, invoice.@ref ?? $"#{id}", "unpaid");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteInvoiceAsync(int id)
|
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",
|
"Secret": "mi-super-secreto-jwt-aqui-cambiar-en-produccion",
|
||||||
"Issuer": "DoliMiddleware",
|
"Issuer": "DoliMiddleware",
|
||||||
"Audience": "DoliClients"
|
"Audience": "DoliClients"
|
||||||
}
|
},
|
||||||
}
|
"Notifications":{
|
||||||
|
"WebhookUrl": ""
|
||||||
|
}}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue