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