using System; using System.Net; using System.Threading; using System.Threading.Tasks; using FastEndpoints; using GB5Shared.DirectAction; using GB5Shared.DTO.DirectAction; using FrameworkBLL.DirectAction; using FrameworkDAL.CustomCode.DirectAction; using FrameworkDAL.DTO.DirectAction; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; namespace FrameworkSL.Endpoints.Action { /// /// GET /Action/Execute?token={signed_token} /// /// Called when a user clicks an Approve / Reject / Return link in a notification email, /// or via any other channel delivering a signed direct-action token. /// Returns an HTML confirmation page — no JSON, no auth header required. /// /// NeedsInput=0: validates → records token → calls API → success page. /// NeedsInput=1: validates → one-time-use check → shows remarks form (token NOT recorded yet). /// Token is recorded only when the user POSTs to /Action/Submit. /// public class ActionExecuteEndpoint : Endpoint { private readonly IDirectActionTokenService _tokenService; private readonly IDirectActionTokenDAL _tokenDAL; private readonly IDirectActionConfigDAL _configDAL; private readonly IGenericApiDirectActionHandler _handler; private readonly ILogger _logger; public ActionExecuteEndpoint( IDirectActionTokenService tokenService, IDirectActionTokenDAL tokenDAL, IDirectActionConfigDAL configDAL, IGenericApiDirectActionHandler handler, ILogger logger) { _tokenService = tokenService; _tokenDAL = tokenDAL; _configDAL = configDAL; _handler = handler; _logger = logger; } public override void Configure() { Get("/Action/Execute"); AllowAnonymous(); Description(b => b.WithTags("DirectAction")); } public override async Task HandleAsync(ActionExecuteRequest req, CancellationToken ct) { _logger.LogInformation("Email Action API hit — token present: {HasToken}", !string.IsNullOrWhiteSpace(req.Token)); try { if (string.IsNullOrWhiteSpace(req.Token)) { await SendHtmlAsync(HtmlPage("Invalid Link", "This action link is missing a token.", false), ct); return; } // ── Validate token ──────────────────────────────────────────────── if (!_tokenService.TryValidate(req.Token, out var actionCode, out var contextId, out var tenantId, out var assigneeUserId, out var databaseName, out var tokenExpiresAt)) { await SendHtmlAsync(HtmlPage("Link Expired or Invalid", "This action link has expired or is not valid.", false), ct); return; } var login = new LoginDTO { ClientId = tenantId, UserId = assigneeUserId, DatabaseName = databaseName, ConnectionDatabaseName = databaseName }; // ── One-time use check ──────────────────────────────────────────── var tokenHash = ComputeHash(req.Token); var existing = await _tokenDAL.GetByHashAsync(tokenHash, login, ct); if (existing != null) { var msg = existing.UsedAt.HasValue ? $"This link was already used on {existing.UsedAt:yyyy-MM-dd HH:mm} UTC." : "This link has been revoked."; await SendHtmlAsync(HtmlPage("Already Actioned", msg, false), ct); return; } // ── NeedsInput=1: show remarks form BEFORE recording the token ───── // Token is only recorded by ActionSubmitEndpoint after form POST, so the // user can reload the form without permanently consuming the one-time-use slot. var config = await _configDAL.GetDetailByActionCodeAsync(actionCode, login, ct) .ConfigureAwait(false); if (config?.NeedsInput == 1) { // FlowId > 0 — a real EIP conversational flow is configured; hand off to // the HTML stepper instead of the static single-field remarks form. if (config.FlowId > 0) { HttpContext.Response.Redirect($"/Action/Conversation?token={Uri.EscapeDataString(req.Token)}"); return; } var label = string.IsNullOrWhiteSpace(config.ActionLabel) ? actionCode : config.ActionLabel; await SendHtmlAsync(RemarksFormHtml(req.Token, label), ct); return; } // ── NeedsInput=0: record token use then call API ────────────────── await _tokenDAL.InsertUsedAsync(new DirectActionTokenDTO { TokenHash = tokenHash, ContextId = contextId, ActionCode = actionCode, AssigneeUserId = assigneeUserId, TenantId = tenantId, ExpiresAt = tokenExpiresAt, UsedAt = DateTime.UtcNow, Status = 1 }, login, ct); // ── Execute the action ──────────────────────────────────────────── // Capture scheme+host of this FrameworkSL instance — same pattern as WIP // capturing login.RequestUrl in BaseEndpoint for the callback URL. var frameworkBaseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}"; var result = await _handler.ExecuteAsync(actionCode, contextId, assigneeUserId, null, login, frameworkBaseUrl, ct); if (!result.Success) { await SendHtmlAsync(HtmlPage("Error", $"The action could not be completed: {System.Net.WebUtility.HtmlEncode(result.ErrorMessage ?? "Unknown error")}", false), ct); return; } await SendHtmlAsync(HtmlPage("Action Successful", $"Action '{System.Net.WebUtility.HtmlEncode(actionCode)}' for item #{contextId} has been processed successfully.", true), ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { _logger.LogInformation("ActionExecuteEndpoint: request cancelled by client"); if (!HttpContext.Response.HasStarted) HttpContext.Response.StatusCode = 499; } catch (Exception ex) { _logger.LogError(ex, "ActionExecuteEndpoint: unhandled error processing token"); if (!HttpContext.Response.HasStarted) await SendHtmlAsync(HtmlPage("System Error", "An unexpected error occurred. Please contact your administrator.", false), ct); } } private async Task SendHtmlAsync(string html, CancellationToken ct) { HttpContext.Response.ContentType = "text/html; charset=utf-8"; await HttpContext.Response.WriteAsync(html, ct); } private static string HtmlPage(string title, string message, bool success) { var color = success ? "#2e7d32" : "#c62828"; var icon = success ? "✓" : "✗"; return $@" {System.Net.WebUtility.HtmlEncode(title)}

{icon} {System.Net.WebUtility.HtmlEncode(title)}

{System.Net.WebUtility.HtmlEncode(message)}

GoodBooks ERP — Action Confirmation

"; } private static string ComputeHash(string token) { using var sha = System.Security.Cryptography.SHA256.Create(); var bytes = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(token)); return Convert.ToHexString(bytes).ToLowerInvariant(); } private static string RemarksFormHtml(string token, string actionLabel) => $@" Provide Remarks

{WebUtility.HtmlEncode(actionLabel)}


GoodBooks ERP — Action Confirmation

"; } public record ActionExecuteRequest([property: QueryParam] string? Token); }