using Fitness_core_BE.Auth;
using Fitness_core_BE.DTOs.Agent;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models.IOs;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace Fitness_core_BE.Controllers
{
///
/// Endpoints consumed by the on-prem desktop agent. The agent talks to the Hikvision
/// device on the gym LAN and to us over outbound HTTPS using its X-Api-Key.
/// All endpoints except enrollment require the "AgentApiKey" policy; the agent's client
/// is always resolved server-side from the authenticated key, never from the request body.
///
[Route("api/agent")]
[ApiController]
[Produces("application/json")]
[EnableCors("AllowAll")]
public class AgentController : ControllerBase
{
private readonly IAgentService _agentService;
private readonly ITimeBankCardService _timeBankCardService;
private readonly ILogger _logger;
public AgentController(IAgentService agentService, ITimeBankCardService timeBankCardService, ILogger logger)
{
_agentService = agentService;
_timeBankCardService = timeBankCardService;
_logger = logger;
}
// Bootstrap: no api-key yet. Exchanges a pairing code for a new agent + api key.
[HttpPost("enroll")]
[AllowAnonymous]
public Task Enroll([FromBody] EnrollRequest request)
=> Execute(() => _agentService.EnrollAsync(request));
[HttpPost("devices")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task RegisterDevice([FromBody] RegisterDeviceRequest request)
=> Execute(() => _agentService.RegisterDeviceAsync(GetClientId(), GetAgentId(), request));
[HttpPost("device-inventory")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task ReconcileDeviceInventory([FromBody] DeviceInventoryRequest request)
=> Execute(() => _agentService.ReconcileDeviceInventoryAsync(
GetClientId(), GetAgentId(), request));
[HttpPost("attendance")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task Attendance([FromBody] AgentAttendanceRequest request)
{
_logger.LogInformation(
"Attendance endpoint hit: device {DeviceId}, {EventCount} events in body.",
request?.DeviceId, request?.Events?.Count ?? 0);
return Execute(() => _agentService.IngestAttendanceAsync(
GetClientId(), GetAgentId(), request));
}
[HttpGet("commands")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task GetCommands()
=> Execute(() => _agentService.GetPendingCommandsAsync(GetClientId(), GetAgentId()));
[HttpPost("commands/{id}/result")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task SubmitCommandResult(Guid id, [FromBody] CommandResultRequest request)
=> Execute(() => _agentService.SubmitCommandResultAsync(
GetClientId(), GetAgentId(), id, request));
// Called by an RFID-reader-equipped agent station on a card swipe -- same
// "agent resolves the club from its own API key" pattern as attendance/commands, so a
// reader never needs staff credentials of its own. facilityId identifies which area the
// reader is stationed at. First swipe on a card taps it in (starts the clock); the next
// swipe taps it out and bills the elapsed time -- see TimeBankCardService.Swipe.
[HttpPost("time-bank-cards/redeem")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public Task RedeemTimeBankCard([FromBody] AgentSwipeTimeBankCardRequest request)
=> Execute(() => _timeBankCardService.Swipe(new
{
rfidTagId = request.RfidTagId,
facilityId = request.FacilityId
}));
[HttpPost("heartbeat")]
[Authorize(Policy = AgentApiKeyDefaults.PolicyName)]
public async Task Heartbeat()
{
// Accept an empty body from older agents during a rolling deployment.
var request = Request.HasJsonContentType()
? await Request.ReadFromJsonAsync()
: null;
return await Execute(() => _agentService.HeartbeatAsync(
GetClientId(), GetAgentId(), request ?? new AgentHeartbeatRequest()));
}
// ---- helpers ----
private Guid GetClientId()
{
var value = User.FindFirst(AgentApiKeyDefaults.ClientIdClaim)?.Value;
if (string.IsNullOrWhiteSpace(value) || !Guid.TryParse(value, out var clientId))
throw new UnauthorizedException("Agent is not authenticated.");
return clientId;
}
private Guid GetAgentId()
{
var value = User.FindFirst(AgentApiKeyDefaults.AgentIdClaim)?.Value;
if (string.IsNullOrWhiteSpace(value) || !Guid.TryParse(value, out var agentId))
throw new UnauthorizedException("Agent is not authenticated.");
return agentId;
}
// Mirrors the function-handler exception->ApiResponse mapping used by admin flows.
private async Task Execute(Func> action)
{
try
{
var data = await action();
return StatusCode(200, new ApiResponse