merge: resolve conflicts with remote — keep local CORS config, merge webhook notifications

- Resolved merge conflicts in Program.cs (kept configurable CORS from local)
- Resolved merge conflicts in DocumentController.cs (kept full controller
  with list/download endpoints from local)
- Integrated remote webhook notification system:
  * INotificationService interface + WebhookNotificationService
  * InvoiceService now sends Teams/Slack webhook on status changes
  * appsettings.json: added Notifications:WebhookUrl config
  * Program.cs: registered INotificationService + Webhook HttpClient
This commit is contained in:
JavMB 2026-05-15 19:02:32 +02:00
commit 12b1755e86
6 changed files with 159 additions and 5 deletions

3
.gitignore vendored
View File

@ -4,6 +4,9 @@ obj/
/packages/ /packages/
out/ out/
## Secrets
appsettings.Development.json
## IDE - Rider / IntelliJ ## IDE - Rider / IntelliJ
.idea/ .idea/
*.iml *.iml

View File

@ -4,6 +4,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;
@ -193,6 +194,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();
// ========================================= // =========================================

View File

@ -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)

View File

@ -0,0 +1,6 @@
namespace DoliMiddlewareApi.Services.Notifications;
public interface INotificationService
{
Task NotifyInvoiceStatusChangedAsync(int invoiceId, string invoiceRef, string newStatus);
}

View File

@ -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" }
}
}
}
};
}
}

View File

@ -18,5 +18,7 @@
"Secret": "", "Secret": "",
"Issuer": "DoliMiddleware", "Issuer": "DoliMiddleware",
"Audience": "DoliClients" "Audience": "DoliClients"
} },
} "Notifications":{
"WebhookUrl": ""
}}