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
{
///
/// POST /Action/Submit (application/x-www-form-urlencoded)
///
/// Handles form submission for DirectAction buttons where NEEDSINPUT=1.
/// Receives the signed token (from the hidden field) and user-provided remarks,
/// validates the token, records it as used (with remarks), then calls the
/// configured API endpoint with remarks substituted into the payload template.
///
public class ActionSubmitEndpoint : Endpoint
{
private readonly IDirectActionTokenService _tokenService;
private readonly IDirectActionTokenDAL _tokenDAL;
private readonly IGenericApiDirectActionHandler _handler;
private readonly ILogger _logger;
public ActionSubmitEndpoint(
IDirectActionTokenService tokenService,
IDirectActionTokenDAL tokenDAL,
IGenericApiDirectActionHandler handler,
ILogger logger)
{
_tokenService = tokenService;
_tokenDAL = tokenDAL;
_handler = handler;
_logger = logger;
}
public override void Configure()
{
Post("/Action/Submit");
AllowAnonymous();
Description(b => b.WithTags("DirectAction"));
}
public override async Task HandleAsync(ActionSubmitRequest req, CancellationToken ct)
{
_logger.LogInformation("ActionSubmit hit — token present: {HasToken}", !string.IsNullOrWhiteSpace(req.Token));
try
{
if (string.IsNullOrWhiteSpace(req.Token))
{
await SendHtmlAsync(HtmlPage("Invalid Request", "No token provided.", false), ct);
return;
}
// ── Validate token (signature + expiry) ───────────────────────────
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;
}
var remarks = req.Remarks?.Trim();
// ── Record token use with remarks ─────────────────────────────────
await _tokenDAL.InsertUsedAsync(new DirectActionTokenDTO
{
TokenHash = tokenHash,
ContextId = contextId,
ActionCode = actionCode,
AssigneeUserId = assigneeUserId,
TenantId = tenantId,
ExpiresAt = tokenExpiresAt,
UsedAt = DateTime.UtcNow,
Status = 1,
Remarks = remarks
}, login, ct);
// ── Execute action with remarks ───────────────────────────────────
var frameworkBaseUrl = $"{HttpContext.Request.Scheme}://{HttpContext.Request.Host}";
var result = await _handler.ExecuteAsync(actionCode, contextId, assigneeUserId, remarks, login, frameworkBaseUrl, ct);
if (!result.Success)
{
await SendHtmlAsync(HtmlPage("Error",
$"The action could not be completed: {WebUtility.HtmlEncode(result.ErrorMessage ?? "Unknown error")}",
false), ct);
return;
}
await SendHtmlAsync(HtmlPage("Action Submitted",
"Your response has been recorded successfully.", true), ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogInformation("ActionSubmitEndpoint: request cancelled by client");
if (!HttpContext.Response.HasStarted)
HttpContext.Response.StatusCode = 499;
}
catch (Exception ex)
{
_logger.LogError(ex, "ActionSubmitEndpoint: unhandled error");
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 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 HtmlPage(string title, string message, bool success)
{
var color = success ? "#2e7d32" : "#c62828";
var icon = success ? "✓" : "✗";
return $@"
{WebUtility.HtmlEncode(title)}
{icon} {WebUtility.HtmlEncode(title)}
{WebUtility.HtmlEncode(message)}
GoodBooks ERP — Action Confirmation
";
}
}
public record ActionSubmitRequest(string? Token, string? Remarks);
}