using Fitness_core_BE.Helpers; using Fitness_core_BE.Infra.Tenancy; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Models.IOs; using Fitness_core_BE.Services; using Microsoft.AspNetCore.Mvc; using static Fitness_core_BE.Helpers.JwtMiddleware; using static Fitness_core_BE.Helpers.ApiResponseHelper; namespace Fitness_core_BE.Controllers { // Platform-Admin-only visibility into every background job, and a manual "run it now" // trigger for each -- one home for this instead of a new controller per job. Each job's // actual algorithm stays in its own runner class (SubscriptionRenewalRunner, // OverdueLockRunner); this controller only reuses those, same as the hosted services do. // Same [CustomMiddleware][TenantResolution] gating as the rest of AdminController. [ApiController] [Route("api/admin/background-jobs")] [CustomMiddleware] [TenantResolution] public class AdminBackgroundJobsController : ControllerBase { private const string RenewalJobName = "SubscriptionRenewalJob"; private readonly TenantSchemaIterator _iterator; private readonly SubscriptionRenewalRunner _renewalRunner; private readonly OverdueLockRunner _overdueLockRunner; private readonly IJobRunHistory _history; private readonly IOverdueLockRepository _overdueLockRepository; // Normal DI-resolved CentralDbContext is fine here (unlike in a background job) -- // this runs inside a real HTTP request, and central is one fixed database regardless // of tenant, so there's no per-request tenant resolution involved either way. private readonly CentralDbContext _central; public AdminBackgroundJobsController( TenantSchemaIterator iterator, SubscriptionRenewalRunner renewalRunner, OverdueLockRunner overdueLockRunner, IJobRunHistory history, IOverdueLockRepository overdueLockRepository, CentralDbContext central) { _iterator = iterator; _renewalRunner = renewalRunner; _overdueLockRunner = overdueLockRunner; _history = history; _overdueLockRepository = overdueLockRepository; _central = central; } [HttpGet("renewal/runs")] public async Task>>> GetRenewalRuns( [FromQuery] int take = 20, CancellationToken ct = default) { return Response(await _history.GetRecentAsync(_central, RenewalJobName, take, ct)); } // Runs a full sweep across every provisioned tenant schema immediately, rather than // waiting for the next daily (01:00 Sri Lanka time) tick. Uses the exact same // SubscriptionRenewalRunner the hosted service itself calls -- no separate/duplicated // renewal logic. Optional clientId scopes the sweep to one gym (e.g. re-running just for // a client that failed, or testing against one tenant without touching every other gym). [HttpPost("renewal/trigger")] public async Task>>> TriggerRenewalNow( [FromQuery] Guid? clientId, CancellationToken ct) { var now = DateTime.UtcNow; var results = new List(); var targets = clientId.HasValue ? SingleClientAsync(clientId.Value, ct) : _iterator.GetProvisionedRegistrationsAsync(ct); await foreach (var (targetClientId, schemaName) in targets) { TenantSchemaContext? schemaCtx = null; try { schemaCtx = _iterator.OpenSchema(targetClientId, schemaName); var result = await _renewalRunner.RunForSchemaAsync(schemaCtx, now, ct); results.Add(result); await _history.RecordAsync(schemaCtx.Central, new JobRunRecord( RenewalJobName, targetClientId, now, result.Failed > 0 ? "PartialFailure" : "Success", result.Renewed, result.Failed > 0 ? string.Join("; ", result.Notes) : null), ct); } catch (Exception ex) { results.Add(new SubscriptionRenewalRunResult(targetClientId, 0, 0, 1, new[] { ex.Message })); schemaCtx ??= _iterator.OpenSchema(targetClientId, schemaName); await _history.RecordAsync(schemaCtx.Central, new JobRunRecord(RenewalJobName, targetClientId, now, "Failed", 0, ex.Message), ct); } finally { if (schemaCtx != null) await schemaCtx.DisposeAsync(); } } return Response(results); } private async IAsyncEnumerable<(Guid ClientId, string SchemaName)> SingleClientAsync( Guid clientId, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct) { await using var schemaCtx = await _iterator.OpenSchemaForClientAsync(clientId, ct); if (schemaCtx == null) yield break; yield return (schemaCtx.ClientId, schemaCtx.SchemaName); } // Runs the overdue-lock sweep across every client with a lock policy immediately, rather // than waiting for the next 6-hour tick. Uses the exact same OverdueLockRunner the // hosted service itself calls. No run-history endpoint yet -- OverdueLockJob doesn't // record to IJobRunHistory today; that's a separate follow-up if it's ever needed. [HttpPost("overdue-lock/trigger")] public async Task>> TriggerOverdueLockNow(CancellationToken ct) { var now = DateTime.UtcNow; var clients = await _overdueLockRepository.GetClientsWithOverdueLockPolicyAsync(ct); var failures = new List(); foreach (var client in clients) { try { await _overdueLockRunner.RunForClientAsync(client, now, _iterator, ct); } catch (Exception ex) { failures.Add($"{client.ClientId}: {ex.Message}"); } } return Response(new { clientsChecked = clients.Count, failed = failures.Count, failures }); } } }