diff --git a/FiMAdminApi.Data/DataContext.cs b/FiMAdminApi.Data/DataContext.cs index 8aa43c7..f324110 100644 --- a/FiMAdminApi.Data/DataContext.cs +++ b/FiMAdminApi.Data/DataContext.cs @@ -17,6 +17,8 @@ public class DataContext(DbContextOptions options) : DbContext(opti public DbSet Matches { get; init; } public DbSet ScheduleDeviations { get; init; } public DbSet TruckRoutes { get; init; } + public DbSet EventTeams { get; init; } + public DbSet EventTeamStatuses { get; set; } protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { diff --git a/FiMAdminApi.Data/Models/EventTeam.cs b/FiMAdminApi.Data/Models/EventTeam.cs new file mode 100644 index 0000000..05e830a --- /dev/null +++ b/FiMAdminApi.Data/Models/EventTeam.cs @@ -0,0 +1,14 @@ +namespace FiMAdminApi.Data.Models; + +public class EventTeam +{ + public int Id { get; set; } + public required Guid EventId { get; set; } + public virtual Event? Event { get; set; } + public required int TeamNumber { get; set; } + public required int LevelId { get; set; } + public virtual Level? Level { get; set; } + public string? Notes { get; set; } + public required string StatusId { get; set; } + public virtual EventTeamStatus? Status { get; set; } +} \ No newline at end of file diff --git a/FiMAdminApi.Data/Models/EventTeamStatus.cs b/FiMAdminApi.Data/Models/EventTeamStatus.cs new file mode 100644 index 0000000..875b8cb --- /dev/null +++ b/FiMAdminApi.Data/Models/EventTeamStatus.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; + +namespace FiMAdminApi.Data.Models; + +public class EventTeamStatus +{ + /// + /// Unique identifier for the status, but still understandable by humans + /// + [Key] + public required string Id { get; set; } + + public required string Name { get; set; } + + /// + /// Higher numbers are teams closer to ready for matches + /// + public required int Ordinal { get; set; } +} + +public static class KnownEventTeamStatuses +{ + public const string Dropped = "Dropped"; + public const string NotArrived = "NotArrived"; +} \ No newline at end of file diff --git a/FiMAdminApi/Clients/BlueAllianceDataClient.cs b/FiMAdminApi/Clients/BlueAllianceDataClient.cs index eb47975..0e173e1 100644 --- a/FiMAdminApi/Clients/BlueAllianceDataClient.cs +++ b/FiMAdminApi/Clients/BlueAllianceDataClient.cs @@ -136,6 +136,8 @@ private static DateTime DateStringToDate(string? input, bool isEndOfDay = false) /// private HttpRequestMessage BuildGetRequest(FormattableString endpoint, Dictionary? queryParams = default) { + Logger.LogWarning("TBA is not fully supported at this time. Some data may not sync properly."); + var request = new HttpRequestMessage(); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(MediaTypeNames.Application.Json)); request.Headers.Add("X-TBA-Auth-Key", _apiKey); diff --git a/FiMAdminApi/Endpoints/EventsEndpoints.cs b/FiMAdminApi/Endpoints/EventsEndpoints.cs index fbf4f2d..e06cb55 100644 --- a/FiMAdminApi/Endpoints/EventsEndpoints.cs +++ b/FiMAdminApi/Endpoints/EventsEndpoints.cs @@ -5,6 +5,7 @@ using FiMAdminApi.Data; using FiMAdminApi.Data.Enums; using FiMAdminApi.Data.Models; +using FiMAdminApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; @@ -33,6 +34,10 @@ public static WebApplication RegisterEventsEndpoints(this WebApplication app, Ap .WithDescription("Remove a staff user for an event"); eventsGroup.MapPost("{eventId:guid}/notes", CreateEventNote) .WithDescription("Create an event note"); + eventsGroup.MapPut("{eventId:guid}/teams", RefreshEventTeams) + .WithDescription("Refresh event teams"); + eventsGroup.MapPut("{eventId:guid}/teams/{eventTeamId:int}", UpdateEventTeam) + .WithDescription("Update event team"); return app; } @@ -218,6 +223,66 @@ private static async Task, NotFound, ForbidHttpResult, Val return TypedResults.Ok(note); } + + private static async Task> RefreshEventTeams( + [FromRoute] Guid eventId, + [FromServices] DataContext dbContext, + [FromServices] EventTeamsService teamsService, + ClaimsPrincipal user, + [FromServices] IAuthorizationService authSvc) + { + var evt = await dbContext.Events.Include(e => e.Season).FirstOrDefaultAsync(e => e.Id == eventId); + if (evt is null) + return TypedResults.NotFound(); + + var isAuthorized = await authSvc.AuthorizeAsync(user, eventId, new EventAuthorizationRequirement + { + NeededEventPermission = EventPermission.Event_ManageTeams, + NeededGlobalPermission = GlobalPermission.Events_Manage + }); + if (!isAuthorized.Succeeded) return TypedResults.Forbid(); + + await teamsService.UpsertEventTeams(evt); + + return TypedResults.Ok(); + } + + private static async Task, NotFound, ForbidHttpResult, ValidationProblem>> UpdateEventTeam( + [FromRoute] Guid eventId, + [FromRoute] int eventTeamId, + [FromBody] UpdateEventTeamRequest request, + [FromServices] DataContext dbContext, + ClaimsPrincipal user, + [FromServices] IAuthorizationService authSvc) + { + var (isValid, validationErrors) = await MiniValidator.TryValidateAsync(request); + if (!isValid) return TypedResults.ValidationProblem(validationErrors); + + var evt = await dbContext.Events.Include(e => e.Season).FirstOrDefaultAsync(e => e.Id == eventId); + if (evt is null) + return TypedResults.NotFound(); + + var isAuthorized = await authSvc.AuthorizeAsync(user, eventId, new EventAuthorizationRequirement + { + NeededEventPermission = EventPermission.Event_ManageTeams, + NeededGlobalPermission = GlobalPermission.Events_Manage + }); + if (!isAuthorized.Succeeded) return TypedResults.Forbid(); + + var eventTeam = await dbContext.EventTeams.FirstOrDefaultAsync(t => t.EventId == evt.Id && t.Id == eventTeamId); + if (eventTeam is null) + return TypedResults.NotFound(); + + eventTeam.StatusId = request.StatusId; + eventTeam.Notes = request.Notes; + + await dbContext.SaveChangesAsync(); + + // Make sure we have the new status in the object we return + await dbContext.Entry(eventTeam).Reference(t => t.Status).LoadAsync(); + + return TypedResults.Ok(eventTeam); + } public class UpdateBasicInfoRequest { @@ -263,4 +328,28 @@ public class CreateEventNoteRequest [MaxLength(4000)] public required string Content { get; set; } } + + public class UpdateEventTeamRequest : IValidatableObject + { + [Required] + [MaxLength(100)] + public required string StatusId { get; set; } + + [MaxLength(4000)] + public string? Notes { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + var matchingStatus = validationContext.GetRequiredService().EventTeamStatuses + .Where(s => s.Id == StatusId).Select(s => s.Id).FirstOrDefault(); + if (string.IsNullOrEmpty(matchingStatus)) + { + yield return new ValidationResult($"Unknown status '{StatusId}'.", new[] { nameof(StatusId) }); + } + else + { + StatusId = matchingStatus; + } + } + } } \ No newline at end of file diff --git a/FiMAdminApi/EventSync/EventSyncServiceExtensions.cs b/FiMAdminApi/EventSync/EventSyncServiceExtensions.cs index 08624ef..fae1ebb 100644 --- a/FiMAdminApi/EventSync/EventSyncServiceExtensions.cs +++ b/FiMAdminApi/EventSync/EventSyncServiceExtensions.cs @@ -9,6 +9,7 @@ public static void AddEventSyncSteps(this IServiceCollection services) var steps = new[] { typeof(InitialSync), + typeof(PopulateEventTeams), typeof(LoadQualSchedule), typeof(UpdateQualResults), typeof(UpdateQualRankings), diff --git a/FiMAdminApi/EventSync/Steps/PopulateEventTeams.cs b/FiMAdminApi/EventSync/Steps/PopulateEventTeams.cs new file mode 100644 index 0000000..ff889ca --- /dev/null +++ b/FiMAdminApi/EventSync/Steps/PopulateEventTeams.cs @@ -0,0 +1,13 @@ +using FiMAdminApi.Clients; +using FiMAdminApi.Data.Models; +using FiMAdminApi.Services; + +namespace FiMAdminApi.EventSync.Steps; + +public class PopulateEventTeams(EventTeamsService teamsService) : EventSyncStep([EventStatus.NotStarted]) +{ + public override async Task RunStep(Event evt, IDataClient eventDataClient) + { + await teamsService.UpsertEventTeams(evt); + } +} \ No newline at end of file diff --git a/FiMAdminApi/Infrastructure/SerializerContext.cs b/FiMAdminApi/Infrastructure/SerializerContext.cs index 0bbc862..a0e8293 100644 --- a/FiMAdminApi/Infrastructure/SerializerContext.cs +++ b/FiMAdminApi/Infrastructure/SerializerContext.cs @@ -16,8 +16,11 @@ namespace FiMAdminApi.Infrastructure; [JsonSerializable(typeof(EventsEndpoints.UpdateBasicInfoRequest))] [JsonSerializable(typeof(EventsEndpoints.UpsertEventStaffRequest))] [JsonSerializable(typeof(EventsEndpoints.CreateEventNoteRequest))] +[JsonSerializable(typeof(EventsEndpoints.UpdateEventTeamRequest))] [JsonSerializable(typeof(User[]))] [JsonSerializable(typeof(EventStaff))] +[JsonSerializable(typeof(EventTeam))] +[JsonSerializable(typeof(EventTeamStatus))] [JsonSerializable(typeof(EventsEndpoints.EventStaffInfo[]))] [JsonSerializable(typeof(EventNote))] [JsonSerializable(typeof(HealthEndpoints.ThinHealthReport))] diff --git a/FiMAdminApi/Program.cs b/FiMAdminApi/Program.cs index 53ed88b..d48a20c 100644 --- a/FiMAdminApi/Program.cs +++ b/FiMAdminApi/Program.cs @@ -15,7 +15,6 @@ using Npgsql.NameTranslation; var builder = WebApplication.CreateSlimBuilder(args); -// builder.Logging.ClearProviders(); builder.Logging.AddConsole(opt => { builder.Configuration.GetSection("Logging:Console").Bind(opt); @@ -66,7 +65,8 @@ o => o.MapEnum("tournament_level", nameTranslator: new NpgsqlNullNameTranslator())); }); -// For authn/authz we're using tokens directly from Supabase. These tokens get validated by the supabase auth infrastructure +// For most authn/authz we're using tokens directly from Supabase. These tokens get validated by the supabase auth +// infrastructure builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddScheme(JwtBearerDefaults.AuthenticationScheme, _ => { }) .AddScheme(EventSyncAuthHandler.EventSyncAuthScheme, _ => { }); @@ -89,7 +89,8 @@ { opt.AddDefaultPolicy(pol => { - pol.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? (string[])["http://localhost:5173"]); + pol.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? + (string[]) ["http://localhost:5173"]); pol.AllowCredentials(); pol.AllowAnyMethod(); pol.AllowAnyHeader(); @@ -98,6 +99,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddClients(); builder.Services.AddEventSyncSteps(); builder.Services.AddOutputCache(); @@ -107,9 +109,10 @@ app.UseOutputCache(); app.UseApiConfiguration(); -if (!bool.TryParse(app.Configuration["RUNNING_IN_CONTAINER"], out var inContainer) || - !inContainer) +// When running in a container, traffic is served over HTTP with an external load balancer handling SSL termination +if (!bool.TryParse(app.Configuration["RUNNING_IN_CONTAINER"], out var inContainer) || !inContainer) app.UseHttpsRedirection(); + app.UseCors(); app.UseApiDocumentation(); diff --git a/FiMAdminApi/Services/EventTeamsService.cs b/FiMAdminApi/Services/EventTeamsService.cs new file mode 100644 index 0000000..9d4b121 --- /dev/null +++ b/FiMAdminApi/Services/EventTeamsService.cs @@ -0,0 +1,45 @@ +using FiMAdminApi.Clients; +using FiMAdminApi.Data; +using FiMAdminApi.Data.Models; +using Microsoft.EntityFrameworkCore; + +namespace FiMAdminApi.Services; + +public class EventTeamsService(DataContext dbContext, IServiceProvider serviceProvider) +{ + public async Task UpsertEventTeams(Event evt) + { + var dataClient = serviceProvider.GetKeyedService(evt.SyncSource); + + if (evt.Code is null || dataClient is null) + { + throw new ApplicationException("Unable to get data client for event"); + } + + if (evt.Season is null) + { + throw new ApplicationException("Season must be included to populate teams"); + } + + var existingTeams = await dbContext.EventTeams.Where(t => t.EventId == evt.Id).ToListAsync(); + var apiTeams = await dataClient.GetTeamsForEvent(evt.Season, evt.Code); + + // Delete removed teams + var removedTeamNumbers = existingTeams.Select(et => et.TeamNumber) + .Except(apiTeams.Select(at => at.TeamNumber)); + await dbContext.EventTeams.Where(t => t.EventId == evt.Id && removedTeamNumbers.Contains(t.TeamNumber)) + .ExecuteUpdateAsync(b => b.SetProperty(t => t.StatusId, KnownEventTeamStatuses.Dropped)); + + // Insert new teams + var addedTeamNumbers = apiTeams.ExceptBy(existingTeams.Select(et => et.TeamNumber), at => at.TeamNumber); + await dbContext.EventTeams.AddRangeAsync(addedTeamNumbers.Select(at => new EventTeam + { + EventId = evt.Id, + TeamNumber = at.TeamNumber, + LevelId = evt.Season.LevelId, + Notes = null, + StatusId = KnownEventTeamStatuses.NotArrived + })); + await dbContext.SaveChangesAsync(); + } +} \ No newline at end of file