using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using CareFix.Api.Infrastructure;
using CareFix.Api.Options;
using CareFix.Api.Tickets;
using Dapper;
using Microsoft.Extensions.Options;
namespace CareFix.Api.Integration;
public sealed record InboundTicket(string HospitalCode, string ExternalRef, string IssueText, string? Title, string RaisedBy);
///
/// Generic helpdesk link. Inbound: the helpdesk creates CareFix tickets with a shared key.
/// Outbound: CareFix posts signed status changes to the helpdesk's webhook URL.
///
public sealed class HelpdeskIntegration(ControlDb db, TicketService tickets, IOptions options, AuditService audit)
{
public void Authenticate(HttpRequest req)
{
var expected = options.Value.Integration.InboundKey;
if (string.IsNullOrEmpty(expected)) throw AppException.NotFound("The helpdesk API is not enabled.");
var given = req.Headers["X-CareFix-Integration-Key"].ToString();
if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(given), Encoding.UTF8.GetBytes(expected)))
throw new AppException(401, "Integration key not valid.");
}
public async Task CreateAsync(InboundTicket i, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(i.ExternalRef)) throw AppException.BadRequest("externalRef is required.");
await using var c = await db.OpenAsync(ct);
var hospitalId = await c.ExecuteScalarAsync("SELECT HospitalId FROM CF_HOSPITAL WHERE Code = @HospitalCode AND Status = 'Active'", new { i.HospitalCode })
?? throw AppException.BadRequest($"No active hospital with code {i.HospitalCode}.");
var existing = await c.QuerySingleOrDefaultAsync<(int TicketId, string TicketNo)>(
"SELECT TicketId, TicketNo FROM CF_TICKET WHERE HospitalId = @hospitalId AND ExternalRef = @ExternalRef", new { hospitalId, i.ExternalRef });
if (existing.TicketNo is not null) return new { ticketId = existing.TicketId, ticketNo = existing.TicketNo, created = false };
var user = await c.QuerySingleOrDefaultAsync<(int UserId, string Role, string FullName)>(
"SELECT UserId, Role, FullName FROM CF_USER WHERE Username = @RaisedBy AND IsActive = 1", new { i.RaisedBy });
if (user.Role is null) throw AppException.BadRequest($"No active CareFix user named {i.RaisedBy}. Use the engineer's CareFix username.");
var principal = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, user.UserId.ToString()),
new Claim(ClaimTypes.Name, user.FullName),
new Claim(ClaimTypes.Role, user.Role),
], "Integration"));
var id = await tickets.CreateAsync(principal, new NewTicketInput(hospitalId, i.Title ?? "", i.IssueText, i.ExternalRef.Trim()), ct);
var ticketNo = await c.ExecuteScalarAsync("SELECT TicketNo FROM CF_TICKET WHERE TicketId = @id", new { id });
await audit.LogAsync(user.UserId, id, "TicketCreatedByHelpdesk", new { i.ExternalRef, i.HospitalCode }, ct);
return new { ticketId = id, ticketNo, created = true };
}
public async Task StatusAsync(string hospitalCode, string externalRef, CancellationToken ct)
{
await using var c = await db.OpenAsync(ct);
return await c.QuerySingleOrDefaultAsync("""
SELECT t.TicketNo AS ticketNo, t.ExternalRef AS externalRef, t.State AS state, t.Risk AS risk, t.CloseNote AS closeNote,
t.UpdatedAt AS updatedAt, t.ClosedAt AS closedAt
FROM CF_TICKET t JOIN CF_HOSPITAL h ON h.HospitalId = t.HospitalId
WHERE h.Code = @hospitalCode AND t.ExternalRef = @externalRef
""", new { hospitalCode, externalRef }) ?? throw AppException.NotFound("No CareFix ticket for that reference.");
}
}
/// Writes status-change events for tickets that came from (or are linked to) the helpdesk.
public sealed class WebhookOutbox(ControlDb db, IOptions options)
{
public async Task EnqueueAsync(int ticketId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(options.Value.Integration.WebhookUrl)) return;
await using var c = await db.OpenAsync(ct);
var t = await c.QuerySingleOrDefaultAsync("""
SELECT t.TicketNo, t.ExternalRef, h.Code AS HospitalCode, t.State, t.Risk, t.CloseNote, t.UpdatedAt,
(SELECT TOP 1 Summary FROM CF_FIX WHERE TicketId = t.TicketId AND State NOT IN ('Superseded','Rejected') ORDER BY FixId DESC) AS FixSummary
FROM CF_TICKET t JOIN CF_HOSPITAL h ON h.HospitalId = t.HospitalId
WHERE t.TicketId = @ticketId AND t.ExternalRef IS NOT NULL
""", new { ticketId });
if (t is null) return;
var payload = JsonSerializer.Serialize(new
{
@event = "ticket.updated",
ticketNo = (string)t.TicketNo, externalRef = (string)t.ExternalRef, hospitalCode = (string)t.HospitalCode,
state = (string)t.State, risk = (string?)t.Risk, fixSummary = (string?)t.FixSummary, closeNote = (string?)t.CloseNote,
updatedAt = ((DateTime)t.UpdatedAt).ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
await c.ExecuteAsync("INSERT CF_WEBHOOK_OUTBOX (TicketId, PayloadJson) VALUES (@ticketId, @payload)", new { ticketId, payload });
}
}
/// Delivers outbox events with an HMAC-SHA256 signature; retries with back-off for about a day.
public sealed class WebhookDispatcher(ControlDb db, IHttpClientFactory http, IOptions options, ILogger log) : BackgroundService
{
private const int MaxAttempts = 12;
private sealed class Item { public long OutboxId { get; set; } public string PayloadJson { get; set; } = ""; public int Attempts { get; set; } }
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var cfg = options.Value.Integration;
if (!string.IsNullOrWhiteSpace(cfg.WebhookUrl))
{
try { await DeliverDueAsync(cfg, stoppingToken); }
catch (Exception ex) when (ex is not OperationCanceledException) { log.LogWarning(ex, "Webhook delivery loop failed"); }
}
try { await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); } catch (OperationCanceledException) { }
}
}
private async Task DeliverDueAsync(IntegrationOptions cfg, CancellationToken ct)
{
await using var c = await db.OpenAsync(ct);
var due = (await c.QueryAsync- (
"SELECT TOP 20 OutboxId, PayloadJson, Attempts FROM CF_WEBHOOK_OUTBOX WHERE State = 'Pending' AND NextAttemptAt <= SYSUTCDATETIME() ORDER BY OutboxId")).ToList();
foreach (var item in due)
{
string? error = null;
try
{
using var req = new HttpRequestMessage(HttpMethod.Post, cfg.WebhookUrl) { Content = new StringContent(item.PayloadJson, Encoding.UTF8, "application/json") };
if (!string.IsNullOrEmpty(cfg.WebhookSecret))
{
var sig = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(cfg.WebhookSecret), Encoding.UTF8.GetBytes(item.PayloadJson))).ToLowerInvariant();
req.Headers.Add("X-CareFix-Signature", "sha256=" + sig);
}
req.Headers.Add("X-CareFix-Delivery", item.OutboxId.ToString());
using var resp = await http.CreateClient("webhook").SendAsync(req, ct);
if (!resp.IsSuccessStatusCode) error = $"HTTP {(int)resp.StatusCode}";
}
catch (Exception ex) when (ex is not OperationCanceledException) { error = ex.Message; }
if (error is null)
await c.ExecuteAsync("UPDATE CF_WEBHOOK_OUTBOX SET State='Sent', SentAt=SYSUTCDATETIME(), Attempts=Attempts+1 WHERE OutboxId=@OutboxId", new { item.OutboxId });
else
{
var attempts = item.Attempts + 1;
var delayMin = Math.Min(Math.Pow(2, attempts), 240);
await c.ExecuteAsync("""
UPDATE CF_WEBHOOK_OUTBOX SET Attempts=@attempts, LastError=@error,
State = CASE WHEN @attempts >= @max THEN 'Failed' ELSE 'Pending' END,
NextAttemptAt = DATEADD(MINUTE, @delayMin, SYSUTCDATETIME())
WHERE OutboxId=@OutboxId
""", new { item.OutboxId, attempts, error = error.Length > 1000 ? error[..1000] : error, max = MaxAttempts, delayMin = (int)delayMin });
}
}
}
}