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 { StatusCode = 200, Message = "success", Data = data }); } catch (UnauthorizedException ex) { return StatusCode(401, new ApiResponse { StatusCode = 401, Message = ex.Message, Data = null! }); } catch (NotFoundException ex) { return StatusCode(404, new ApiResponse { StatusCode = 404, Message = ex.Message, Data = null! }); } catch (BadRequestException ex) { return StatusCode(400, new ApiResponse { StatusCode = 400, Message = ex.Message, Data = null! }); } catch (ForbiddenException ex) { return StatusCode(403, new ApiResponse { StatusCode = 403, Message = ex.Message, Data = null! }); } catch (Exception ex) { return StatusCode(500, new ApiResponse { StatusCode = 500, Message = $"An error occurred: {ex.Message}", Data = null! }); } } } }