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 }); } } } }