Sube capa Infrastructure, Solution y archivos de configuracion del Backend
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Infrastructure.Persistance.Converters;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
using System;
|
||||
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <summary>
|
||||
/// Serves as the main EF Core database context, with entity mappings defined in Infrastructure.Persistance.Configurations and applied via ModelBuilder.ApplyConfigurationsFromAssembly.
|
||||
/// </summary>
|
||||
public class ApplicationDBContext(DbContextOptions<ApplicationDBContext> options)
|
||||
: DbContext(options), IClub12DBContext
|
||||
{
|
||||
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||
{
|
||||
configurationBuilder
|
||||
.Properties<DateTime>()
|
||||
.HaveColumnType("timestamp without time zone")
|
||||
.HaveConversion<UtcDateTimeConverter>();
|
||||
|
||||
configurationBuilder
|
||||
.Properties<DateTime?>()
|
||||
.HaveColumnType("timestamp without time zone")
|
||||
.HaveConversion<NullableUtcDateTimeConverter>();
|
||||
|
||||
base.ConfigureConventions(configurationBuilder);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDBContext).Assembly);
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
|
||||
public virtual required DbSet<Club> Clubs { get; set; }
|
||||
public virtual required DbSet<BackupRecord> BackupRecords { get; set; }
|
||||
public virtual required DbSet<Team> Teams { get; set; }
|
||||
public virtual required DbSet<Player> Players { get; set; }
|
||||
public virtual required DbSet<Tournament> Tournaments { get; set; }
|
||||
public virtual required DbSet<Division> Divisions { get; set; }
|
||||
public virtual required DbSet<DivisionPlayoffMapping> DivisionPlayoffMappings { get; set; }
|
||||
public virtual required DbSet<TeamPointDeduction> TeamPointDeductions { get; set; }
|
||||
public virtual required DbSet<TeamStaff> TeamStaffs { get; set; }
|
||||
public virtual required DbSet<Match> Matches { get; set; }
|
||||
public virtual required DbSet<MatchSeries> MatchSeries { get; set; }
|
||||
public virtual required DbSet<PlayerStatistic> PlayersStatistics { get; set; }
|
||||
public virtual required DbSet<PlayerSanction> PlayerSanctions { get; set; }
|
||||
public virtual required DbSet<Venue> Venues { get; set; }
|
||||
public virtual required DbSet<Season> Seasons { get; set; }
|
||||
public virtual required DbSet<BlogPost> BlogPosts { get; set; }
|
||||
public virtual required DbSet<Stage> Stages { get; set; }
|
||||
public virtual required DbSet<StageTeamMatch> StageTeamMatches { get; set; }
|
||||
public virtual required DbSet<Scorer> Scorers { get; set; }
|
||||
public virtual required DbSet<PlayerTeamRegistration> PlayerTeamRegistrations { get; set; }
|
||||
public virtual required DbSet<TeamTournamentRegistration> TeamTournamentRegistrations { get; set; }
|
||||
public virtual required DbSet<DivisionTeamRegistration> DivisionTeamRegistrations { get; set; }
|
||||
public virtual required DbSet<AuditLog> AuditLogs { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Application.Utils.Constants.Configuration;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a design-time instance of ApplicationDBContext for EF Core CLI tools, loading all appsettings files and environment variables so the model can build without running the API host.
|
||||
/// </summary>
|
||||
public sealed class ApplicationDBContextFactory : IDesignTimeDbContextFactory<ApplicationDBContext>
|
||||
{
|
||||
public ApplicationDBContext CreateDbContext(string[] args)
|
||||
{
|
||||
string basePath = ResolveBasePath();
|
||||
|
||||
string aspNetEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")
|
||||
?? "Development";
|
||||
|
||||
ConfigurationBuilder builder = new();
|
||||
builder.SetBasePath(basePath);
|
||||
builder.AddJsonFile("appsettings.json", optional: true);
|
||||
builder.AddJsonFile($"appsettings.{aspNetEnv}.json", optional: true);
|
||||
|
||||
if (Directory.Exists(basePath))
|
||||
{
|
||||
string?[] extras = Directory
|
||||
.GetFiles(basePath, "appsettings.*.json")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(f => !string.Equals(
|
||||
f, $"appsettings.{aspNetEnv}.json",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
foreach (string? file in extras)
|
||||
{
|
||||
builder.AddJsonFile(file!, optional: true);
|
||||
}
|
||||
}
|
||||
|
||||
IConfigurationRoot config = builder.Build();
|
||||
|
||||
string connectionString = config.GetConnectionString(ConfigurationKeys.DbConnection)
|
||||
?? Environment.GetEnvironmentVariable($"ConnectionStrings__{ConfigurationKeys.DbConnection}")
|
||||
?? throw new InvalidOperationException(
|
||||
$"Connection string '{ConfigurationKeys.DbConnection}' not found.\n" +
|
||||
$"Searched in: {basePath}\n" +
|
||||
$"Files loaded: appsettings.json, appsettings.{aspNetEnv}.json + all appsettings.*.json");
|
||||
|
||||
DbContextOptionsBuilder<ApplicationDBContext> optionsBuilder = new();
|
||||
optionsBuilder.UseNpgsql(connectionString);
|
||||
|
||||
return new ApplicationDBContext(optionsBuilder.Options)
|
||||
{
|
||||
Clubs = null!,
|
||||
BackupRecords = null!,
|
||||
Teams = null!,
|
||||
Players = null!,
|
||||
Tournaments = null!,
|
||||
Divisions = null!,
|
||||
DivisionPlayoffMappings = null!,
|
||||
TeamPointDeductions = null!,
|
||||
TeamStaffs = null!,
|
||||
Matches = null!,
|
||||
MatchSeries = null!,
|
||||
PlayersStatistics = null!,
|
||||
PlayerSanctions = null!,
|
||||
Venues = null!,
|
||||
Seasons = null!,
|
||||
BlogPosts = null!,
|
||||
Stages = null!,
|
||||
StageTeamMatches = null!,
|
||||
Scorers = null!,
|
||||
PlayerTeamRegistrations = null!,
|
||||
TeamTournamentRegistrations = null!,
|
||||
DivisionTeamRegistrations = null!,
|
||||
AuditLogs = null!,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveBasePath()
|
||||
{
|
||||
string current = Directory.GetCurrentDirectory();
|
||||
|
||||
string[] candidates =
|
||||
[
|
||||
current,
|
||||
Path.Combine(current, "API"),
|
||||
Path.Combine(current, "..", "API"),
|
||||
];
|
||||
|
||||
string? match = Array.Find(candidates, path => File.Exists(Path.Combine(path, "appsettings.json")));
|
||||
|
||||
return match is null ? current : Path.GetFullPath(match);
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core mapping for the sensitive-action audit trail.
|
||||
/// </summary>
|
||||
public class AuditLogEntityConfiguration : BaseEntityConfiguration<AuditLog>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<AuditLog> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.AuditLog, EntityConstants.Schema);
|
||||
|
||||
// Action is persisted as the enum name string, mirroring other enum columns in this DB, including Match.Status and PlayerTeamRegistration.MedicalRecordStatus.
|
||||
builder.Property(a => a.Action).IsRequired().HasConversion<string>();
|
||||
builder.Property(a => a.Actor).IsRequired().HasMaxLength(256);
|
||||
builder.Property(a => a.TargetType).HasMaxLength(128);
|
||||
builder.Property(a => a.TargetId).HasMaxLength(128);
|
||||
builder.Property(a => a.TargetName).HasMaxLength(256);
|
||||
builder.Property(a => a.Detail).HasMaxLength(1024);
|
||||
|
||||
builder.HasIndex(a => a.Action);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class BackupRecordEntityConfiguration : BaseEntityConfiguration<BackupRecord>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<BackupRecord> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.BackupRecord, EntityConstants.Schema);
|
||||
|
||||
builder.Property(b => b.StoragePath).IsRequired().HasMaxLength(500);
|
||||
builder.Property(b => b.SizeBytes).IsRequired();
|
||||
builder.Property(b => b.Origin).IsRequired().HasConversion<string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Base EF Core configuration shared by every entity, mapping the primary key, audit timestamps, and a DateCreated index.
|
||||
/// </summary>
|
||||
public abstract class BaseEntityConfiguration<TEntity> : IEntityTypeConfiguration<TEntity>
|
||||
where TEntity : EntityBase
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public void Configure(EntityTypeBuilder<TEntity> builder)
|
||||
{
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id)
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
builder.Property(e => e.DateCreated)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(e => e.DateUpdated);
|
||||
|
||||
builder.HasIndex(e => e.DateCreated)
|
||||
.HasDatabaseName($"IX_{typeof(TEntity).Name}_CreatedAt");
|
||||
|
||||
ConfigureEntity(builder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override to configure entity-specific table, columns, indexes, and relationships.
|
||||
/// </summary>
|
||||
protected abstract void ConfigureEntity(EntityTypeBuilder<TEntity> builder);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class BlogPostEntityConfiguration : BaseEntityConfiguration<BlogPost>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<BlogPost> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.BlogPost, EntityConstants.Schema);
|
||||
|
||||
builder.Property(b => b.Author).IsRequired().HasMaxLength(50);
|
||||
builder.Property(b => b.Title).IsRequired();
|
||||
builder.Property(b => b.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(b => b.PhotoUrl).HasMaxLength(2048);
|
||||
builder.Property(b => b.MarkdownText).IsRequired();
|
||||
|
||||
// Defaults to true so every pre-existing post back-filled by the migration stays publicly visible, and any future insert that omits the flag is treated as published.
|
||||
builder.Property(b => b.IsPublished).IsRequired().HasDefaultValue(true);
|
||||
|
||||
builder.HasIndex(b => b.Slug).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the Club to Team relationship as optional and non-cascading, so deleting a Club only nulls its teams' ClubId instead of removing them.
|
||||
/// </summary>
|
||||
public class ClubEntityConfiguration : BaseEntityConfiguration<Club>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Club> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Club, EntityConstants.Schema);
|
||||
|
||||
builder.Property(c => c.Name).IsRequired();
|
||||
builder.Property(c => c.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(c => c.LogoUrl);
|
||||
|
||||
builder.HasIndex(c => c.Slug).IsUnique();
|
||||
|
||||
builder.HasMany(c => c.Teams)
|
||||
.WithOne(t => t.Club)
|
||||
.HasForeignKey(t => t.ClubId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Self-referencing, one level deep: deleting the parent club unlinks its
|
||||
// squads instead of cascading, and Restrict avoids the multiple-cascade-path
|
||||
// error SQL Server/SQLite raise for a self-join with SetNull here too.
|
||||
builder.HasMany(c => c.ChildClubs)
|
||||
.WithOne(c => c.ParentClub)
|
||||
.HasForeignKey(c => c.ParentClubId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using Application.Utils.Constants.Validation;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class DivisionEntityConfiguration : BaseEntityConfiguration<Division>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Division> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Division, EntityConstants.Schema);
|
||||
|
||||
builder.Property(d => d.Name).IsRequired().HasMaxLength(DivisionFieldLengths.NameMaxLength);
|
||||
builder.Property(d => d.Slug).IsRequired().HasMaxLength(220);
|
||||
|
||||
builder.HasIndex(d => d.Slug).IsUnique();
|
||||
|
||||
builder.HasOne(d => d.Tournament)
|
||||
.WithMany(t => t.Divisions)
|
||||
.HasForeignKey(d => d.TournamentId)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(d => d.PointsForWin).IsRequired().HasDefaultValue(2);
|
||||
builder.Property(d => d.PointsForLoss).IsRequired().HasDefaultValue(1);
|
||||
builder.Property(d => d.QualifiersPerGroup).IsRequired().HasDefaultValue(1);
|
||||
builder.Property(d => d.Category).IsRequired().HasDefaultValue(TournamentCategory.Masculine);
|
||||
|
||||
builder.HasMany(d => d.Stages)
|
||||
.WithOne(s => s.Division)
|
||||
.HasForeignKey(s => s.DivisionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(d => d.PlayoffMappings)
|
||||
.WithOne(m => m.Division)
|
||||
.HasForeignKey(m => m.DivisionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class DivisionPlayoffMappingEntityConfiguration : BaseEntityConfiguration<DivisionPlayoffMapping>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<DivisionPlayoffMapping> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.DivisionPlayoffMapping, EntityConstants.Schema);
|
||||
|
||||
builder.Property(m => m.FromPosition).IsRequired();
|
||||
builder.Property(m => m.ToPosition).IsRequired();
|
||||
builder.Property(m => m.Destination).IsRequired().HasMaxLength(100);
|
||||
|
||||
builder.HasIndex(m => m.DivisionId);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// A team is enrolled in the same division at most once, enforced by a unique index on TeamId and DivisionId.
|
||||
/// </summary>
|
||||
public class DivisionTeamRegistrationEntityConfiguration : BaseEntityConfiguration<DivisionTeamRegistration>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<DivisionTeamRegistration> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.DivisionTeamRegistration, EntityConstants.Schema);
|
||||
|
||||
builder.Property(r => r.TeamId).IsRequired();
|
||||
builder.Property(r => r.DivisionId).IsRequired();
|
||||
|
||||
builder.HasIndex(r => new { r.TeamId, r.DivisionId }).IsUnique();
|
||||
|
||||
builder.HasIndex(r => r.DivisionId);
|
||||
|
||||
builder.HasOne(r => r.Team)
|
||||
.WithMany(t => t.DivisionTeamRegistrations)
|
||||
.HasForeignKey(r => r.TeamId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(r => r.Division)
|
||||
.WithMany(d => d.DivisionTeamRegistrations)
|
||||
.HasForeignKey(r => r.DivisionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Match has multiple optional foreign keys to Team, each configured explicitly to avoid EF Core relationship ambiguity.
|
||||
/// </summary>
|
||||
public class MatchEntityConfiguration : BaseEntityConfiguration<Match>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Match> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Match, EntityConstants.Schema);
|
||||
|
||||
builder.Property(m => m.Type).IsRequired().HasConversion<string>();
|
||||
builder.Property(m => m.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(m => m.IsFinished).IsRequired();
|
||||
builder.Property(m => m.Status).IsRequired().HasConversion<string>();
|
||||
builder.Property(m => m.StageId).IsRequired();
|
||||
|
||||
builder.HasIndex(m => m.Slug).IsUnique();
|
||||
|
||||
builder.HasOne(m => m.Stage)
|
||||
.WithMany(s => s.Matches)
|
||||
.HasForeignKey(m => m.StageId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(m => m.HomeTeam)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.HomeTeamId)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(m => m.VisitorTeam)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.VisitorTeamId)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(m => m.WinningTeam)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.WinningTeamId)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(m => m.Venue)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.VenueId)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasOne(m => m.Series)
|
||||
.WithMany(ms => ms.Matches)
|
||||
.HasForeignKey(m => m.SeriesId)
|
||||
.IsRequired(false);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// MatchSeries has multiple foreign keys to Team, each configured explicitly to avoid EF Core relationship ambiguity.
|
||||
/// </summary>
|
||||
public class MatchSeriesEntityConfiguration : BaseEntityConfiguration<MatchSeries>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<MatchSeries> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.MatchSeries, EntityConstants.Schema);
|
||||
|
||||
builder.Property(ms => ms.StageId).IsRequired();
|
||||
builder.Property(ms => ms.HomeTeamId).IsRequired();
|
||||
builder.Property(ms => ms.VisitorTeamId).IsRequired();
|
||||
|
||||
builder.HasOne(ms => ms.Stage)
|
||||
.WithMany(s => s.MatchSeries)
|
||||
.HasForeignKey(ms => ms.StageId)
|
||||
.IsRequired();
|
||||
|
||||
// Explicit Restrict overrides EF's default Cascade for required FKs so a team with an active series is never silently deleted along with its series history if TeamService.DeleteTeamAsync's guard is ever bypassed.
|
||||
builder.HasOne(ms => ms.HomeTeam)
|
||||
.WithMany()
|
||||
.HasForeignKey(ms => ms.HomeTeamId)
|
||||
.IsRequired()
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(ms => ms.VisitorTeam)
|
||||
.WithMany()
|
||||
.HasForeignKey(ms => ms.VisitorTeamId)
|
||||
.IsRequired()
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(ms => ms.WinningTeam)
|
||||
.WithMany()
|
||||
.HasForeignKey(ms => ms.WinningTeamId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasMany(ms => ms.Matches)
|
||||
.WithOne(m => m.Series)
|
||||
.HasForeignKey(m => m.SeriesId)
|
||||
.IsRequired(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// FullName is ignored because it is a computed C# property, not a DB column.
|
||||
/// </summary>
|
||||
public class PlayerEntityConfiguration : BaseEntityConfiguration<Player>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Player> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Player, EntityConstants.Schema);
|
||||
|
||||
builder.Property(p => p.FirstName).IsRequired().HasMaxLength(70);
|
||||
builder.Property(p => p.SecondName).HasMaxLength(70);
|
||||
builder.Property(p => p.LastName).IsRequired().HasMaxLength(70);
|
||||
builder.Property(p => p.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(p => p.DocumentNumber).IsRequired().HasMaxLength(15);
|
||||
builder.Property(p => p.IsSanctioned).IsRequired();
|
||||
builder.Property(p => p.BirthDate).IsRequired();
|
||||
builder.Property(p => p.SocialSecurity).IsRequired().HasMaxLength(100);
|
||||
|
||||
builder.Ignore(p => p.FullName);
|
||||
|
||||
builder.HasIndex(p => p.DocumentNumber).IsUnique();
|
||||
|
||||
builder.HasIndex(p => p.Slug).IsUnique();
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class PlayerSanctionEntityConfiguration : BaseEntityConfiguration<PlayerSanction>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<PlayerSanction> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.PlayerSanction, EntityConstants.Schema);
|
||||
|
||||
builder.Property(ps => ps.Duration).IsRequired();
|
||||
builder.Property(ps => ps.IssuedDate).IsRequired();
|
||||
builder.Property(ps => ps.Description).IsRequired().HasMaxLength(255);
|
||||
builder.Property(ps => ps.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(ps => ps.SubjectType).IsRequired().HasConversion<string>();
|
||||
builder.Property(ps => ps.StaffName).HasMaxLength(255);
|
||||
builder.Property(ps => ps.AppealStatus).IsRequired().HasConversion<string>();
|
||||
builder.Property(ps => ps.AppealReason).HasMaxLength(1000);
|
||||
builder.Property(ps => ps.AppealResolution).HasMaxLength(1000);
|
||||
|
||||
builder.HasIndex(ps => ps.Slug).IsUnique();
|
||||
|
||||
// OnDelete is stated explicitly because the existing migrations already applied Cascade for Player and Restrict for Team, and without it the optional FKs would default to NoAction and drift from the persisted schema.
|
||||
builder.HasOne(ps => ps.Player)
|
||||
.WithMany()
|
||||
.HasForeignKey(ps => ps.PlayerId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(ps => ps.Team)
|
||||
.WithMany()
|
||||
.HasForeignKey(ps => ps.TeamId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
builder.HasOne(ps => ps.Match)
|
||||
.WithMany()
|
||||
.HasForeignKey(ps => ps.MatchId)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class PlayerStatisticEntityConfiguration : BaseEntityConfiguration<PlayerStatistic>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<PlayerStatistic> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.PlayerStatistic, EntityConstants.Schema);
|
||||
|
||||
builder.Property(ps => ps.Value).IsRequired();
|
||||
|
||||
builder.Property(ps => ps.Type).IsRequired().HasConversion<string>();
|
||||
|
||||
builder.HasOne(ps => ps.Match)
|
||||
.WithMany(m => m.PlayerStatistics)
|
||||
.HasForeignKey(ps => ps.MatchId)
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(ps => ps.Player)
|
||||
.WithMany()
|
||||
.HasForeignKey(ps => ps.PlayerId)
|
||||
.IsRequired();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// A player may have at most one registration per tournament season, enforced by a unique index on PlayerId and TournamentId, so a player can never be on two teams in one season.
|
||||
/// </summary>
|
||||
public class PlayerTeamRegistrationEntityConfiguration : BaseEntityConfiguration<PlayerTeamRegistration>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<PlayerTeamRegistration> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.PlayerTeamRegistration, EntityConstants.Schema);
|
||||
|
||||
builder.Property(r => r.PlayerId).IsRequired();
|
||||
builder.Property(r => r.TeamId).IsRequired();
|
||||
builder.Property(r => r.TournamentId).IsRequired();
|
||||
|
||||
// Status is persisted as the enum name string, mirroring other enum columns in this DB, including Match.Status, PlayerStatistic.Type, and PlayerSanction.AppealStatus.
|
||||
builder.Property(r => r.MedicalRecordStatus).IsRequired().HasConversion<string>();
|
||||
builder.Property(r => r.MedicalRecordFileUrl);
|
||||
builder.Property(r => r.MedicalRecordFileName);
|
||||
builder.Property(r => r.MedicalRecordReviewReason);
|
||||
builder.Property(r => r.MedicalRecordReviewedAt);
|
||||
|
||||
builder.HasIndex(r => new { r.PlayerId, r.TournamentId }).IsUnique();
|
||||
|
||||
// Both Npgsql and SQLite treat NULLs as distinct in a unique index, so this allows any number of players with no assigned dorsal while rejecting duplicate non-null dorsals, with no provider-specific filtered index required.
|
||||
builder.Property(r => r.JerseyNumber);
|
||||
builder.HasIndex(r => new { r.TeamId, r.TournamentId, r.JerseyNumber }).IsUnique();
|
||||
|
||||
builder.HasOne(r => r.Player)
|
||||
.WithMany(p => p.PlayerTeamRegistrations)
|
||||
.HasForeignKey(r => r.PlayerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(r => r.Team)
|
||||
.WithMany(t => t.PlayerTeamRegistrations)
|
||||
.HasForeignKey(r => r.TeamId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(r => r.Tournament)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.TournamentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Scorer is a service-layer read-model with no DataAnnotations, so there is nothing to migrate beyond the mappings configured here.
|
||||
/// </summary>
|
||||
public class ScorerEntityConfiguration : BaseEntityConfiguration<Scorer>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Scorer> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Scorer, EntityConstants.Schema);
|
||||
|
||||
builder.Property(s => s.PlayerId).IsRequired();
|
||||
builder.Property(s => s.Points).IsRequired();
|
||||
builder.Property(s => s.MatchId).IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Player)
|
||||
.WithMany(p => p.Scorers)
|
||||
.HasForeignKey(s => s.PlayerId);
|
||||
|
||||
builder.HasOne(s => s.Match)
|
||||
.WithMany(m => m.Scorers)
|
||||
.HasForeignKey(s => s.MatchId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Application.Utils.Constants.Validation;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class SeasonEntityConfiguration : BaseEntityConfiguration<Season>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Season> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Season, EntityConstants.Schema);
|
||||
|
||||
builder.Property(s => s.Name).IsRequired().HasMaxLength(SeasonFieldLengths.NameMaxLength);
|
||||
builder.Property(s => s.Slug).IsRequired().HasMaxLength(220);
|
||||
|
||||
builder.HasIndex(s => s.Slug).IsUnique();
|
||||
|
||||
// Deleting a season detaches its tournaments via SetNull instead of cascading, since a season is a purely additive grouping and must never delete a tournament.
|
||||
builder.HasMany(s => s.Tournaments)
|
||||
.WithOne(t => t.Season)
|
||||
.HasForeignKey(t => t.SeasonId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class StageEntityConfiguration : BaseEntityConfiguration<Stage>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Stage> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Stage, EntityConstants.Schema);
|
||||
|
||||
builder.Property(s => s.Name).IsRequired().HasMaxLength(100);
|
||||
builder.Property(s => s.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(s => s.Description).HasMaxLength(1000);
|
||||
builder.Property(s => s.StageType).IsRequired().HasConversion<string>();
|
||||
builder.Property(s => s.IsActive).IsRequired();
|
||||
builder.Property(s => s.StartDate).IsRequired();
|
||||
builder.Property(s => s.EndDate).IsRequired();
|
||||
builder.Property(s => s.DivisionId).IsRequired();
|
||||
builder.Property(s => s.BracketName).HasMaxLength(100);
|
||||
builder.Property(s => s.BestOf).IsRequired().HasDefaultValue(1);
|
||||
builder.Property(s => s.RoundRobinLegs).IsRequired().HasDefaultValue(1);
|
||||
|
||||
builder.HasIndex(s => new { s.Name, s.DivisionId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName(EntityConstants.Indexes.UniqueStageNameDivision);
|
||||
|
||||
builder.HasIndex(s => s.Slug).IsUnique();
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class StageTeamMatchEntityConfiguration : BaseEntityConfiguration<StageTeamMatch>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<StageTeamMatch> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.StageTeamMatch, EntityConstants.Schema);
|
||||
|
||||
builder.Property(stm => stm.StageId).IsRequired();
|
||||
builder.Property(stm => stm.TeamId).IsRequired();
|
||||
|
||||
builder.HasOne(stm => stm.Stage)
|
||||
.WithMany(s => s.StageTeamMatches)
|
||||
.HasForeignKey(stm => stm.StageId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(stm => stm.Team)
|
||||
.WithMany(t => t.StageTeamMatches)
|
||||
.HasForeignKey(stm => stm.TeamId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// TournamentId is an optional foreign key, since a Team may exist without a tournament.
|
||||
/// </summary>
|
||||
public class TeamEntityConfiguration : BaseEntityConfiguration<Team>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Team> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Team, EntityConstants.Schema);
|
||||
|
||||
builder.Property(t => t.Name).IsRequired();
|
||||
builder.Property(t => t.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(t => t.ThreeLetterCode).IsRequired();
|
||||
builder.Property(t => t.LogoUrl).IsRequired();
|
||||
builder.Property(t => t.ShirtColor).IsRequired();
|
||||
builder.Property(t => t.JerseyStyle).IsRequired().HasMaxLength(20).HasDefaultValue("solid");
|
||||
builder.Property(t => t.ShirtSecondaryColor).HasMaxLength(9);
|
||||
builder.Property(t => t.ShirtTertiaryColor).HasMaxLength(9);
|
||||
|
||||
builder.HasIndex(t => t.Slug).IsUnique();
|
||||
|
||||
builder.HasOne(t => t.Tournament)
|
||||
.WithMany(tourn => tourn.Teams)
|
||||
.HasForeignKey(t => t.TournamentId)
|
||||
.IsRequired(false);
|
||||
|
||||
builder.HasMany(t => t.Players)
|
||||
.WithOne(p => p.Team)
|
||||
.HasForeignKey(p => p.TeamId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Deleting a division cascades its point deductions away, but the team foreign key is Restrict, so a team that still carries deductions is not silently removed.
|
||||
/// </summary>
|
||||
public class TeamPointDeductionEntityConfiguration : BaseEntityConfiguration<TeamPointDeduction>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<TeamPointDeduction> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.TeamPointDeduction, EntityConstants.Schema);
|
||||
|
||||
builder.Property(d => d.Points).IsRequired();
|
||||
builder.Property(d => d.Reason).IsRequired().HasMaxLength(300);
|
||||
|
||||
builder.HasIndex(d => d.DivisionId);
|
||||
|
||||
builder.HasOne(d => d.Division)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.DivisionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(d => d.Team)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.TeamId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// A team staff row carries no independent value once its team or tournament is gone, so both foreign keys cascade.
|
||||
/// </summary>
|
||||
public class TeamStaffEntityConfiguration : BaseEntityConfiguration<TeamStaff>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<TeamStaff> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.TeamStaff, EntityConstants.Schema);
|
||||
|
||||
builder.Property(s => s.FullName).IsRequired().HasMaxLength(150);
|
||||
builder.Property(s => s.Role).IsRequired();
|
||||
|
||||
builder.HasIndex(s => new { s.TeamId, s.TournamentId });
|
||||
|
||||
builder.HasOne(s => s.Team)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.TeamId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(s => s.Tournament)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.TournamentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// A team may be registered to the same tournament only once, enforced by a unique index on TeamId and TournamentId, but may hold independent registrations across multiple tournaments at once.
|
||||
/// </summary>
|
||||
public class TeamTournamentRegistrationEntityConfiguration : BaseEntityConfiguration<TeamTournamentRegistration>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<TeamTournamentRegistration> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.TeamTournamentRegistration, EntityConstants.Schema);
|
||||
|
||||
builder.Property(r => r.TeamId).IsRequired();
|
||||
builder.Property(r => r.TournamentId).IsRequired();
|
||||
|
||||
builder.HasIndex(r => new { r.TeamId, r.TournamentId }).IsUnique();
|
||||
|
||||
builder.HasOne(r => r.Team)
|
||||
.WithMany(t => t.TeamTournamentRegistrations)
|
||||
.HasForeignKey(r => r.TeamId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasOne(r => r.Tournament)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.TournamentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
public class TournamentEntityConfiguration : BaseEntityConfiguration<Tournament>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Tournament> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Tournament, EntityConstants.Schema, t =>
|
||||
t.HasCheckConstraint(
|
||||
EntityConstants.CheckConstraints.TournamentDeadlineBeforeStart,
|
||||
"\"TeamRegistrationDeadline\" < \"StartDate\""
|
||||
)
|
||||
);
|
||||
|
||||
builder.Property(t => t.Name).IsRequired();
|
||||
builder.Property(t => t.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(t => t.Description).IsRequired();
|
||||
builder.Property(t => t.TeamRegistrationDeadline).IsRequired();
|
||||
builder.Property(t => t.StartDate).IsRequired();
|
||||
builder.Property(t => t.Status).HasDefaultValue(TournamentStatus.Scheduled).IsRequired();
|
||||
builder.Property(t => t.Category).HasDefaultValue(TournamentCategory.Masculine).IsRequired();
|
||||
|
||||
builder.HasIndex(t => t.Slug).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace Infrastructure.Persistance.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// PhotoUrl is intentionally not configured here since its format is enforced by the ImageReference attribute on the request DTO, a validation-only concern with no corresponding database constraint.
|
||||
/// </summary>
|
||||
public class VenueEntityConfiguration : BaseEntityConfiguration<Venue>
|
||||
{
|
||||
protected override void ConfigureEntity(EntityTypeBuilder<Venue> builder)
|
||||
{
|
||||
builder.ToTable(EntityConstants.Tables.Venue, EntityConstants.Schema);
|
||||
|
||||
builder.Property(v => v.Name).IsRequired().HasMaxLength(50);
|
||||
builder.Property(v => v.Slug).IsRequired().HasMaxLength(220);
|
||||
builder.Property(v => v.Address).IsRequired().HasMaxLength(200);
|
||||
|
||||
builder.HasIndex(v => v.Slug).IsUnique();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
using System;
|
||||
|
||||
namespace Infrastructure.Persistance.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Nullable variant of UtcDateTimeConverter.
|
||||
/// </summary>
|
||||
public class NullableUtcDateTimeConverter()
|
||||
: ValueConverter<DateTime?, DateTime?>(
|
||||
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Unspecified) : null,
|
||||
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : null);
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
using System;
|
||||
|
||||
namespace Infrastructure.Persistance.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// Converts DateTime values to Unspecified for storage and back to UTC on read, to satisfy the timestamp without time zone column type.
|
||||
/// </summary>
|
||||
public class UtcDateTimeConverter()
|
||||
: ValueConverter<DateTime, DateTime>(
|
||||
v => DateTime.SpecifyKind(v, DateTimeKind.Unspecified),
|
||||
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||
@@ -0,0 +1,50 @@
|
||||
using Application.Interfaces.Backup;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <summary>
|
||||
/// IBackupCatalog implementation backed directly by ApplicationDBContext.BackupRecords, kept as a thin single-entity wrapper instead of routing through GenericRepository or UnitOfWork to match the lightweight port style established for the rest of the Backup feature.
|
||||
/// </summary>
|
||||
public sealed class EfBackupCatalog(ApplicationDBContext context) : IBackupCatalog
|
||||
{
|
||||
public async Task<BackupRecord> AddAsync(BackupRecord record, CancellationToken ct = default)
|
||||
{
|
||||
context.BackupRecords.Add(record);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return record;
|
||||
}
|
||||
|
||||
public async Task<BackupRecord?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
return await context.BackupRecords.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BackupRecord>> ListNewestFirstAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await context.BackupRecords
|
||||
.OrderByDescending(r => r.DateCreated)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RemoveAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
BackupRecord? record = await context.BackupRecords.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||
if (record is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
context.BackupRecords.Remove(record);
|
||||
await context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <summary>
|
||||
/// Centralizes all magic strings used across EF Core configurations:
|
||||
/// schema, table names, column type names, index names, and filter expressions.
|
||||
/// </summary>
|
||||
public static class EntityConstants
|
||||
{
|
||||
/// <summary>
|
||||
/// Club12 database schema.
|
||||
/// </summary>
|
||||
public const string Schema = "Club12";
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server datetime2 column type.
|
||||
/// </summary>
|
||||
public const string DateTime2 = "datetime2";
|
||||
|
||||
/// <summary>
|
||||
/// SQL Server decimal column type with precision 18 and scale 4.
|
||||
/// </summary>
|
||||
public const string Decimal18x4 = "decimal(18,4)";
|
||||
|
||||
public static class Tables
|
||||
{
|
||||
public const string AuditLog = "AuditLogs";
|
||||
public const string BackupRecord = "BackupRecords";
|
||||
public const string BlogPost = "BlogPosts";
|
||||
public const string Club = "Clubs";
|
||||
public const string Division = "Divisions";
|
||||
public const string DivisionPlayoffMapping = "DivisionPlayoffMappings";
|
||||
public const string DivisionTeamRegistration = "DivisionTeamRegistrations";
|
||||
public const string Match = "Matches";
|
||||
public const string MatchSeries = "MatchSeries";
|
||||
public const string Player = "Players";
|
||||
public const string PlayerSanction = "PlayerSanctions";
|
||||
|
||||
/// <summary>
|
||||
/// Uses the plural form PlayersStatistics to match the value already baked into existing migrations, unlike the singular naming used by the other table constants.
|
||||
/// </summary>
|
||||
public const string PlayerStatistic = "PlayersStatistics";
|
||||
|
||||
public const string PlayerTeamRegistration = "PlayerTeamRegistrations";
|
||||
public const string Position = "Positions";
|
||||
public const string Scorer = "Scorers";
|
||||
public const string Season = "Seasons";
|
||||
public const string Stage = "Stages";
|
||||
public const string StageTeamMatch = "StageTeamMatches";
|
||||
public const string Staff = "Staffs";
|
||||
public const string Team = "Teams";
|
||||
public const string TeamPointDeduction = "TeamPointDeductions";
|
||||
public const string TeamStaff = "TeamStaffs";
|
||||
public const string TeamTournamentRegistration = "TeamTournamentRegistrations";
|
||||
public const string Tournament = "Tournaments";
|
||||
public const string User = "Users";
|
||||
public const string Venue = "Venues";
|
||||
}
|
||||
|
||||
public static class Indexes
|
||||
{
|
||||
public const string UniqueStageNameDivision = "CONSTRAINT_UNIQUE_STAGE_NAME_AND_DIVISIONID";
|
||||
}
|
||||
|
||||
public static class CheckConstraints
|
||||
{
|
||||
public const string TournamentDeadlineBeforeStart = "CK_Tournament_DeadlineBeforeStart";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Domain.Entities.Models;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <summary>
|
||||
/// Defines the database context interface for the Club12 application, providing access to the DbSets representing its entities.
|
||||
/// </summary>
|
||||
public interface IClub12DBContext
|
||||
{
|
||||
DbSet<BackupRecord> BackupRecords { get; }
|
||||
|
||||
DbSet<Team> Teams { get; }
|
||||
|
||||
DbSet<Player> Players { get; }
|
||||
|
||||
DbSet<Tournament> Tournaments { get; }
|
||||
|
||||
DbSet<Division> Divisions { get; }
|
||||
|
||||
DbSet<Match> Matches { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Best-of-N playoff series where each series groups the individual Match rows played between the same two teams.
|
||||
/// </summary>
|
||||
DbSet<MatchSeries> MatchSeries { get; }
|
||||
|
||||
DbSet<PlayerStatistic> PlayersStatistics { get; }
|
||||
|
||||
/// <summary>
|
||||
/// A sanction's subject may be a player, a team, or a staff member as defined by PlayerSanction.SubjectType, despite the entity's name.
|
||||
/// </summary>
|
||||
DbSet<PlayerSanction> PlayerSanctions { get; }
|
||||
|
||||
DbSet<Venue> Venues { get; }
|
||||
|
||||
DbSet<BlogPost> BlogPosts { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Seasons, called Temporadas, group tournaments.
|
||||
/// </summary>
|
||||
DbSet<Season> Seasons { get; }
|
||||
|
||||
DbSet<Stage> Stages { get; }
|
||||
|
||||
DbSet<StageTeamMatch> StageTeamMatches { get; }
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,333 @@
|
||||
using Application.DTOs.DataMaintenance.Response;
|
||||
using Application.Interfaces.Services;
|
||||
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <inheritdoc cref="IDataMaintenanceService"/>
|
||||
public sealed class DataMaintenanceService(
|
||||
ApplicationDBContext db,
|
||||
ILogger<DataMaintenanceService> logger,
|
||||
IAuditService auditService)
|
||||
: IDataMaintenanceService
|
||||
{
|
||||
private static readonly string[] PrimeraNames =
|
||||
[
|
||||
"Atlético Central", "Deportivo Norte", "Club Belgrano", "Unión del Sur",
|
||||
"Racing Porteño", "Defensores del Oeste", "San Lorenzo del Valle", "Gimnasia y Tiro",
|
||||
];
|
||||
private static readonly string[] PrimeraCodes =
|
||||
["ATC", "DNO", "CBE", "UDS", "RPO", "DOE", "SLV", "GYT"];
|
||||
private static readonly string[] PrimeraColors =
|
||||
["#1E3A8A", "#DC2626", "#16A34A", "#EA580C", "#0891B2", "#7C3AED", "#CA8A04", "#0D9488"];
|
||||
private static readonly string[] PrimeraStyles =
|
||||
["stripes", "hoops", "diagonal", "chevron", "sash", "sides", "halves", "circles"];
|
||||
private static readonly string[] PrimeraSecondaryColors =
|
||||
["#FFFFFF", "#FFFFFF", "#FFFFFF", "#1E293B", "#FFFFFF", "#FDE047", "#1E293B", "#FFFFFF"];
|
||||
|
||||
private static readonly string[] SegundaNames =
|
||||
[
|
||||
"Juventud Unida", "Sportivo Oeste", "Estrella Azul", "Náutico River",
|
||||
"Ferro Andino", "Talleres del Norte", "Huracán del Litoral", "Vélez Serrano",
|
||||
];
|
||||
private static readonly string[] SegundaCodes =
|
||||
["JUN", "SPO", "EAZ", "NRV", "FAN", "TDN", "HDL", "VSE"];
|
||||
private static readonly string[] SegundaColors =
|
||||
["#4338CA", "#B91C1C", "#4D7C0F", "#9333EA", "#0284C7", "#65A30D", "#C026D3", "#B45309"];
|
||||
private static readonly string[] SegundaStyles =
|
||||
["gradient", "vneck", "solid", "halves", "sides", "circles", "stripes", "hoops"];
|
||||
private static readonly string[] SegundaSecondaryColors =
|
||||
["#FDE047", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#1E293B", "#FFFFFF", "#FDE047"];
|
||||
|
||||
// Playoff cups shared by both main divisions: Copa Oro takes positions 1 to 4 and Copa Plata takes positions 5 to 8, each a best-of-3 SemiFinal and Final bracket.
|
||||
private static readonly SampleTournamentBuilder.PlayoffCupDefinition[] MainCups =
|
||||
[
|
||||
new("Copa Oro", 1, 4, 3),
|
||||
new("Copa Plata", 5, 8, 3),
|
||||
];
|
||||
|
||||
// Historical Clausura — two small 4-team divisions with single-bracket playoffs.
|
||||
private static readonly string[] ClausuraPrimeraNames =
|
||||
["Independiente Rural", "Ferroviario Central", "Atlético Cordillera", "Deportivo Litoral"];
|
||||
private static readonly string[] ClausuraPrimeraCodes = ["IRU", "FCE", "ACO", "DLI"];
|
||||
private static readonly string[] ClausuraPrimeraColors =
|
||||
["#0D9488", "#B91C1C", "#4D7C0F", "#9333EA"];
|
||||
|
||||
private static readonly string[] ClausuraReservaNames =
|
||||
["Newell's Barrial", "Talleres del Oeste", "Huracán del Valle", "Vélez del Parque"];
|
||||
private static readonly string[] ClausuraReservaCodes = ["NBA", "TDO", "HDV", "VDP"];
|
||||
private static readonly string[] ClausuraReservaColors =
|
||||
["#0284C7", "#65A30D", "#C026D3", "#B45309"];
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DataWipeResult> WipeSampleDataAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using Microsoft.EntityFrameworkCore.Storage.IDbContextTransaction transaction =
|
||||
await db.Database.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
int scorers = await db.Scorers.ExecuteDeleteAsync(ct);
|
||||
int playerStatistics = await db.PlayersStatistics.ExecuteDeleteAsync(ct);
|
||||
int playerSanctions = await db.PlayerSanctions.ExecuteDeleteAsync(ct);
|
||||
int stageTeamMatches = await db.StageTeamMatches.ExecuteDeleteAsync(ct);
|
||||
int playerTeamRegistrations = await db.PlayerTeamRegistrations.ExecuteDeleteAsync(ct);
|
||||
int matches = await db.Matches.ExecuteDeleteAsync(ct);
|
||||
int matchSeries = await db.MatchSeries.ExecuteDeleteAsync(ct);
|
||||
int players = await db.Players.ExecuteDeleteAsync(ct);
|
||||
int stages = await db.Stages.ExecuteDeleteAsync(ct);
|
||||
int teams = await db.Teams.ExecuteDeleteAsync(ct);
|
||||
int divisions = await db.Divisions.ExecuteDeleteAsync(ct);
|
||||
int tournaments = await db.Tournaments.ExecuteDeleteAsync(ct);
|
||||
// Seasons are deleted after their tournaments so re-seeding never collides on the season's unique slug; the season count is not surfaced in DataWipeResult since the counters are kept stable for the admin UI summary.
|
||||
await db.Seasons.ExecuteDeleteAsync(ct);
|
||||
int venues = await db.Venues.ExecuteDeleteAsync(ct);
|
||||
int blogPosts = await db.BlogPosts.ExecuteDeleteAsync(ct);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
logger.LogInformation(
|
||||
"Sample data wiped: {TournamentCount} tournaments, {DivisionCount} divisions, " +
|
||||
"{TeamCount} teams, {PlayerCount} players, {MatchCount} matches, {MatchSeriesCount} match series, " +
|
||||
"{PlayerSanctionCount} player sanctions, {PlayerStatisticCount} player statistics, " +
|
||||
"{ScorerCount} scorers, {StageTeamMatchCount} stage-team matches, " +
|
||||
"{PlayerTeamRegistrationCount} player-team registrations, {StageCount} stages, " +
|
||||
"{VenueCount} venues, {BlogPostCount} blog posts.",
|
||||
tournaments, divisions, teams, players, matches, matchSeries,
|
||||
playerSanctions, playerStatistics, scorers, stageTeamMatches,
|
||||
playerTeamRegistrations, stages, venues, blogPosts);
|
||||
|
||||
// Records the destructive wipe for traceability, written after commit so it survives the wipe since the audit table is not part of the tournament-domain data being deleted.
|
||||
await auditService.LogAsync(
|
||||
AuditAction.DataWipe,
|
||||
detail:
|
||||
$"Se eliminaron los datos de torneos: {tournaments} torneos, {teams} equipos, " +
|
||||
$"{players} jugadores, {matches} partidos.",
|
||||
ct: ct);
|
||||
|
||||
return new DataWipeResult(
|
||||
Tournaments: tournaments,
|
||||
Divisions: divisions,
|
||||
Teams: teams,
|
||||
Players: players,
|
||||
Matches: matches,
|
||||
MatchSeries: matchSeries,
|
||||
PlayerSanctions: playerSanctions,
|
||||
PlayerStatistics: playerStatistics,
|
||||
Scorers: scorers,
|
||||
StageTeamMatches: stageTeamMatches,
|
||||
PlayerTeamRegistrations: playerTeamRegistrations,
|
||||
Stages: stages,
|
||||
Venues: venues,
|
||||
BlogPosts: blogPosts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DataSeedResult> SeedSampleDataAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (await db.Tournaments.AnyAsync(ct))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The database already has tournament data — call WipeSampleDataAsync first, then seed again.");
|
||||
}
|
||||
|
||||
List<Venue> venues = BuildVenues();
|
||||
|
||||
SampleTournamentBuilder.TournamentDefinition tournament1 = new(
|
||||
Name: "Torneo Apertura 2026",
|
||||
Description: "Torneo Apertura de la Liga Club 12, temporada 2026.",
|
||||
TeamRegistrationDeadline: new DateTime(2026, 5, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
StartDate: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StageStartDate: new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StageEndDate: new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Divisions:
|
||||
[
|
||||
new("Primera División", PrimeraNames, PrimeraCodes, PrimeraColors, MainCups,
|
||||
TeamStyles: PrimeraStyles, TeamSecondaryColors: PrimeraSecondaryColors),
|
||||
new("Segunda División", SegundaNames, SegundaCodes, SegundaColors, MainCups,
|
||||
TeamStyles: SegundaStyles, TeamSecondaryColors: SegundaSecondaryColors),
|
||||
],
|
||||
CrossCup: new("Copa Club 12", GroupCount: 4, QualifiersPerGroup: 1));
|
||||
|
||||
SampleTournamentBuilder.TournamentDefinition tournament2 = new(
|
||||
Name: "Torneo Clausura 2026",
|
||||
Description: "Torneo Clausura de la Liga Club 12, temporada 2026.",
|
||||
TeamRegistrationDeadline: new DateTime(2026, 9, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
StartDate: new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StageStartDate: new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
StageEndDate: new DateTime(2027, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Divisions:
|
||||
[
|
||||
new("Primera", ClausuraPrimeraNames, ClausuraPrimeraCodes, ClausuraPrimeraColors),
|
||||
new("Reserva", ClausuraReservaNames, ClausuraReservaCodes, ClausuraReservaColors),
|
||||
],
|
||||
// Historical: the Clausura is a past, completed edition.
|
||||
Status: TournamentStatus.Finished);
|
||||
|
||||
int playerCounter = 0;
|
||||
// Both tournaments share one transaction, so their division and stage slugs must be unique across the batch; a shared registry disambiguates repeated base slugs with numeric suffixes instead of appending GUIDs.
|
||||
SampleTournamentBuilder.SlugRegistry slugRegistry = new();
|
||||
SampleTournamentBuilder.BuildResult result1 =
|
||||
SampleTournamentBuilder.Build(tournament1, venues, ref playerCounter, includePlayoffs: true, slugRegistry);
|
||||
SampleTournamentBuilder.BuildResult result2 =
|
||||
SampleTournamentBuilder.Build(tournament2, venues, ref playerCounter, includePlayoffs: true, slugRegistry);
|
||||
|
||||
// Groups both 2026 tournaments under one season so the additive Season grouping is visible in seeded data; each tournament keeps its own category, since the season only groups them.
|
||||
Season season2026 = new()
|
||||
{
|
||||
CreatedBy = Domain.Constants.AuditConstants.SystemUser,
|
||||
Name = "Temporada 2026",
|
||||
Slug = Application.Utils.Helper.Slug.SlugGenerator.GenerateSlug("Temporada 2026"),
|
||||
Year = 2026,
|
||||
};
|
||||
result1.Tournament.Season = season2026;
|
||||
result2.Tournament.Season = season2026;
|
||||
|
||||
db.Seasons.Add(season2026);
|
||||
db.Tournaments.Add(result1.Tournament);
|
||||
db.Tournaments.Add(result2.Tournament);
|
||||
db.PlayerSanctions.AddRange(result1.Sanctions);
|
||||
db.PlayerSanctions.AddRange(result2.Sanctions);
|
||||
|
||||
List<BlogPost> blogPosts = BuildBlogPosts(tournament1.Name, tournament2.Name);
|
||||
db.BlogPosts.AddRange(blogPosts);
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Unlike DataSeeder.SeedAsync, this reset skips the medical-records backfill because that would require this class to depend on IMedicalRecordStorage, which its DI-resolved tests have no override for; seeded registrations read as not-habilitado until reviewed for real.
|
||||
int teamCount = result1.Tournament.Teams.Count + result2.Tournament.Teams.Count;
|
||||
int playerCount = result1.Tournament.Teams.Sum(t => t.Players.Count)
|
||||
+ result2.Tournament.Teams.Sum(t => t.Players.Count);
|
||||
int divisionCount = result1.Tournament.Divisions.Count + result2.Tournament.Divisions.Count;
|
||||
int matchCount = result1.Tournament.Divisions.Sum(d => d.Stages.Sum(s => s.Matches.Count))
|
||||
+ result2.Tournament.Divisions.Sum(d => d.Stages.Sum(s => s.Matches.Count));
|
||||
int sanctionCount = result1.Sanctions.Count + result2.Sanctions.Count;
|
||||
|
||||
logger.LogInformation(
|
||||
"Sample data seeded: 2 tournaments, {DivisionCount} divisions, {TeamCount} teams, " +
|
||||
"{PlayerCount} players, {MatchCount} matches, {SanctionCount} sanctions, {BlogPostCount} blog posts.",
|
||||
divisionCount, teamCount, playerCount, matchCount, sanctionCount, blogPosts.Count);
|
||||
|
||||
return new DataSeedResult(
|
||||
Tournaments: 2,
|
||||
Divisions: divisionCount,
|
||||
Teams: teamCount,
|
||||
Players: playerCount,
|
||||
Matches: matchCount,
|
||||
PlayerSanctions: sanctionCount,
|
||||
BlogPosts: blogPosts.Count);
|
||||
}
|
||||
|
||||
private static List<Venue> BuildVenues()
|
||||
{
|
||||
// Real basketball club gyms from Paraná, Entre Ríos, with approximate coordinates around the city near -31.73, -60.52.
|
||||
(string Name, string Address, double Latitude, double Longitude)[] specs =
|
||||
[
|
||||
("Estadio Ángel Malvicino (Echagüe)", "Av. Almafuerte, Paraná", -31.7398, -60.5060),
|
||||
("Gimnasio Estudiantes de Paraná", "Gualeguaychú 100, Paraná", -31.7255, -60.5150),
|
||||
("Paraná Rowing Club", "Av. Costanera, Paraná", -31.7150, -60.4890),
|
||||
("Club Sionista", "25 de Mayo, Paraná", -31.7345, -60.5250),
|
||||
("Club Atlético Talleres (Paraná)", "Av. Ramírez, Paraná", -31.7460, -60.5300),
|
||||
("Polideportivo Municipal Paraná", "Parque Urquiza, Paraná", -31.7205, -60.5050),
|
||||
];
|
||||
|
||||
List<Venue> venues = [];
|
||||
foreach ((string name, string address, double latitude, double longitude) in specs)
|
||||
{
|
||||
venues.Add(new Venue
|
||||
{
|
||||
CreatedBy = Domain.Constants.AuditConstants.SystemUser,
|
||||
Name = name,
|
||||
Slug = Application.Utils.Helper.Slug.SlugGenerator.GenerateSlug(name),
|
||||
Address = address,
|
||||
PhotoUrl = SampleArtwork.VenuePhotoDataUri(name),
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
});
|
||||
}
|
||||
|
||||
return venues;
|
||||
}
|
||||
|
||||
private static List<BlogPost> BuildBlogPosts(string tournament1Name, string tournament2Name)
|
||||
{
|
||||
(string Title, string Body, bool Published)[] posts =
|
||||
[
|
||||
(
|
||||
$"Arrancó el {tournament1Name}",
|
||||
$"La Liga Club12 dio el puntapié inicial al {tournament1Name}, con la Primera y la " +
|
||||
"Segunda División de ocho equipos cada una. Los primeros partidos ya se jugaron y " +
|
||||
"prometen una temporada muy pareja.",
|
||||
true
|
||||
),
|
||||
(
|
||||
"Primera División: se juega la fecha 1",
|
||||
"La Primera División puso primera con una jornada inaugural cargada de emociones. " +
|
||||
"Los candidatos mostraron sus cartas y la tabla de posiciones empieza a tomar forma.",
|
||||
true
|
||||
),
|
||||
(
|
||||
"Copa Cruzada: los cruces confirmados",
|
||||
"La Copa Club 12 reúne a los dieciséis equipos del torneo en cuatro grupos. Los " +
|
||||
"partidos se disputan los miércoles para no superponerse con las zonas de los domingos. " +
|
||||
"Ya están confirmados los cruces de la fase de grupos.",
|
||||
true
|
||||
),
|
||||
(
|
||||
"Copa Oro y Copa Plata: así quedaron los playoffs",
|
||||
"Con la fase de grupos terminada, los cuatro primeros de cada división avanzan a la " +
|
||||
"Copa Oro y del quinto al octavo a la Copa Plata. Las semifinales se jugarán al mejor " +
|
||||
"de tres partidos.",
|
||||
true
|
||||
),
|
||||
(
|
||||
"Resumen de la fecha: goleadores y resultados",
|
||||
"Repasá los resultados de la última jornada, la tabla de goleadores y las sanciones " +
|
||||
"aplicadas por el tribunal de disciplina en la sección de estadísticas.",
|
||||
true
|
||||
),
|
||||
(
|
||||
$"Se define el calendario del {tournament2Name}",
|
||||
$"Con el {tournament1Name} en marcha, la Liga Club12 ya prepara las fechas del " +
|
||||
$"{tournament2Name}. La nota está en revisión y se publicará con el fixture completo.",
|
||||
false
|
||||
),
|
||||
];
|
||||
|
||||
List<BlogPost> blogPosts = [];
|
||||
|
||||
foreach ((string title, string body, bool published) in posts)
|
||||
{
|
||||
blogPosts.Add(new BlogPost
|
||||
{
|
||||
CreatedBy = Domain.Constants.AuditConstants.SystemUser,
|
||||
Author = "Redacción Club12",
|
||||
Title = title,
|
||||
Slug = Application.Utils.Helper.Slug.SlugGenerator.GenerateSlug(title),
|
||||
PhotoUrl = SampleArtwork.BlogCoverDataUri(title),
|
||||
MarkdownText = body,
|
||||
Views = 0,
|
||||
IsPublished = published,
|
||||
});
|
||||
}
|
||||
|
||||
return blogPosts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
using Application.Interfaces.Storage;
|
||||
using Application.Utils.Helper.Slug;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Infrastructure.Storage;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the standard sample dataset for the Club 12 basketball league used in local development and demos.
|
||||
/// </summary>
|
||||
public sealed class DataSeeder(
|
||||
ApplicationDBContext db,
|
||||
ILogger<DataSeeder> logger,
|
||||
SupabaseHelper supabaseHelper,
|
||||
MedicalRecordSeedBackfiller medicalRecordSeedBackfiller)
|
||||
{
|
||||
// Fixed seed keeps logo-to-team assignment reproducible across reseeds.
|
||||
private const int LogoShuffleSeed = 4212;
|
||||
|
||||
private const int LatestSeasonNumber = 25;
|
||||
private const int LatestSeasonYear = 2026;
|
||||
private const int MaxSeasonCount = 12;
|
||||
private const int MinPlayersPerTeam = 5;
|
||||
private const int MaxPlayersPerTeam = 20;
|
||||
|
||||
private const int ClausuraPlayedRounds = 5;
|
||||
|
||||
private const int UpsetPercent = 26;
|
||||
|
||||
private static readonly string[] FemeninoNames =
|
||||
[
|
||||
"Patronato", "Peñarol de Paraná", "Bancario", "Sportivo Urquiza",
|
||||
"Belgrano de Paraná", "Deportivo Libertad", "Juventud Unida de Gualeguaychú",
|
||||
];
|
||||
private static readonly string[] FemeninoCodes =
|
||||
["PAT", "PEN", "BAN", "SUR", "BEP", "LIB", "JUG"];
|
||||
private static readonly string[] FemeninoColors =
|
||||
["#0F766E", "#BE123C", "#15803D", "#7E22CE", "#1D4ED8", "#A16207", "#C2410C"];
|
||||
private static readonly string[] FemeninoStyles =
|
||||
["hoops", "stripes", "chevron", "diagonal", "halves", "sash", "circles"];
|
||||
private static readonly string[] FemeninoSecondaryColors =
|
||||
["#FFFFFF", "#FDE047", "#FFFFFF", "#1E293B", "#FFFFFF", "#1E293B", "#FFFFFF"];
|
||||
|
||||
private static readonly string[] ZonaANames =
|
||||
[
|
||||
"Echagüe", "Estudiantes de Paraná", "Paraná Rowing Club", "Sionista",
|
||||
"Recreativo", "Quique", "Olimpia", "Talleres de Paraná",
|
||||
"Central Entrerriano", "Parque Sur",
|
||||
];
|
||||
private static readonly string[] ZonaACodes =
|
||||
["ECH", "EDP", "ROW", "SIO", "REC", "QUI", "OLI", "TDP", "CEN", "PSU"];
|
||||
private static readonly string[] ZonaAColors =
|
||||
[
|
||||
"#1E3A8A", "#DC2626", "#16A34A", "#EA580C", "#0891B2",
|
||||
"#7C3AED", "#CA8A04", "#0D9488", "#4338CA", "#B91C1C",
|
||||
];
|
||||
private static readonly string[] ZonaAStyles =
|
||||
[
|
||||
"stripes", "hoops", "diagonal", "chevron", "sash",
|
||||
"sides", "halves", "circles", "gradient", "vneck",
|
||||
];
|
||||
private static readonly string[] ZonaASecondaryColors =
|
||||
[
|
||||
"#FFFFFF", "#FFFFFF", "#FFFFFF", "#1E293B", "#FFFFFF",
|
||||
"#FDE047", "#1E293B", "#FFFFFF", "#FDE047", "#FFFFFF",
|
||||
];
|
||||
|
||||
private static readonly string[] ZonaBNames =
|
||||
[
|
||||
"Regatas Uruguay", "Estudiantes de Concordia", "Social y Deportivo Colón", "Neptunia",
|
||||
"Independiente de Victoria", "Ciudad de Paraná",
|
||||
"Rocamora", "Gimnasia y Esgrima de CdelU", "Unión de Crespo", "Sarmiento de La Paz",
|
||||
];
|
||||
private static readonly string[] ZonaBCodes =
|
||||
["REG", "EDC", "SDC", "NEP", "IDV", "CDP", "ROC", "GEU", "UCR", "SLP"];
|
||||
private static readonly string[] ZonaBColors =
|
||||
[
|
||||
"#4D7C0F", "#9333EA", "#0284C7", "#65A30D", "#C026D3",
|
||||
"#B45309", "#1D4ED8", "#B91C1C", "#15803D", "#C2410C",
|
||||
];
|
||||
private static readonly string[] ZonaBStyles =
|
||||
[
|
||||
"solid", "halves", "sides", "circles", "stripes",
|
||||
"hoops", "stripes", "hoops", "diagonal", "chevron",
|
||||
];
|
||||
private static readonly string[] ZonaBSecondaryColors =
|
||||
[
|
||||
"#FFFFFF", "#FFFFFF", "#FFFFFF", "#1E293B", "#FFFFFF",
|
||||
"#FDE047", "#FFFFFF", "#FFFFFF", "#FFFFFF", "#1E293B",
|
||||
];
|
||||
|
||||
private static readonly string[] ZonaCNames =
|
||||
[
|
||||
"Atlético Gualeguay", "Capuchinos", "Ministerio de Villa Elisa", "Litoral de Colón",
|
||||
"Sportivo Diamante", "Atlético Federación", "Defensores de Bovril", "Unión de Nogoyá",
|
||||
"Ferrocarril Concepción", "Deportivo Chajarí", "Sarmiento de Villaguay",
|
||||
"Atlético María Grande", "Unión de Viale",
|
||||
];
|
||||
private static readonly string[] ZonaCCodes =
|
||||
["AGY", "CAP", "MVE", "LDC", "SPD", "ATF", "DBO", "UNO", "FCC", "DCH", "SVI", "AMG", "UVI"];
|
||||
private static readonly string[] ZonaCColors =
|
||||
[
|
||||
"#0E7490", "#6D28D9", "#A16207", "#0F766E", "#0EA5E9", "#DB2777", "#65A30D",
|
||||
"#EA580C", "#7C2D12", "#4C1D95", "#0F172A", "#B45309", "#166534",
|
||||
];
|
||||
private static readonly string[] ZonaCStyles =
|
||||
[
|
||||
"sash", "sides", "halves", "circles", "stripes", "hoops", "diagonal",
|
||||
"chevron", "sash", "sides", "halves", "circles", "solid",
|
||||
];
|
||||
private static readonly string[] ZonaCSecondaryColors =
|
||||
[
|
||||
"#FFFFFF", "#FDE047", "#1E293B", "#FFFFFF", "#FFFFFF", "#FDE047", "#FFFFFF",
|
||||
"#1E293B", "#FFFFFF", "#1E293B", "#FFFFFF", "#FDE047", "#FFFFFF",
|
||||
];
|
||||
|
||||
private sealed record ClubSpec(string Name, string Code, string Color, string Style, string Secondary);
|
||||
|
||||
private static ClubSpec[] Zip(
|
||||
string[] names, string[] codes, string[] colors, string[] styles, string[] secondary) =>
|
||||
[.. names.Select((name, i) => new ClubSpec(name, codes[i], colors[i], styles[i], secondary[i]))];
|
||||
|
||||
private static readonly ClubSpec[] FeminineClubs =
|
||||
Zip(FemeninoNames, FemeninoCodes, FemeninoColors, FemeninoStyles, FemeninoSecondaryColors);
|
||||
|
||||
private static readonly ClubSpec[] MasculineClubs =
|
||||
[
|
||||
.. Zip(ZonaANames, ZonaACodes, ZonaAColors, ZonaAStyles, ZonaASecondaryColors),
|
||||
.. Zip(ZonaBNames, ZonaBCodes, ZonaBColors, ZonaBStyles, ZonaBSecondaryColors),
|
||||
.. Zip(ZonaCNames, ZonaCCodes, ZonaCColors, ZonaCStyles, ZonaCSecondaryColors),
|
||||
];
|
||||
|
||||
// BestOf 3 applies to each cup's SemiFinal and Final only; every earlier round always plays best-of-1.
|
||||
private static readonly SampleTournamentBuilder.PlayoffCupDefinition[] FemeninoCups =
|
||||
[
|
||||
new("Copa de Oro", FromPosition: 1, ToPosition: 7, BestOf: 3),
|
||||
];
|
||||
private static readonly SampleTournamentBuilder.PlayoffCupDefinition[] ZonaABCups =
|
||||
[
|
||||
new("Copa Oro", FromPosition: 1, ToPosition: 4, BestOf: 3),
|
||||
new("Copa Plata", FromPosition: 5, ToPosition: 8, BestOf: 3),
|
||||
];
|
||||
private static readonly SampleTournamentBuilder.PlayoffCupDefinition[] ZonaCCups =
|
||||
[
|
||||
new("Copa Oro", FromPosition: 1, ToPosition: 4, BestOf: 3),
|
||||
new("Copa Plata", FromPosition: 5, ToPosition: 13, BestOf: 3),
|
||||
];
|
||||
|
||||
// TeamPoolSize reuses real rosters from the Zona A, B, and C teams in build order instead of inventing a disjoint set of teams, mirroring how real leagues share club rosters across parallel competitions.
|
||||
private static readonly SampleTournamentBuilder.CrossCupDefinition CopaCruzada = new(
|
||||
"Copa Cruzada",
|
||||
GroupCount: 6,
|
||||
QualifiersPerGroup: 2,
|
||||
RoundRobinLegs: 2,
|
||||
FinalsBestOf: 3,
|
||||
TeamPoolSize: 23);
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the sample league, skipping the work when any team already exists.
|
||||
/// </summary>
|
||||
public async Task SeedAsync(
|
||||
bool reset = false, string? logosPath = null,
|
||||
string? medicalRecordPath = null,
|
||||
int seasonCount = 1, int playersPerTeam = SampleTournamentBuilder.DefaultPlayersPerTeam)
|
||||
{
|
||||
if (reset)
|
||||
{
|
||||
await ResetSeededDataAsync();
|
||||
}
|
||||
else if (await db.Teams.AnyAsync())
|
||||
{
|
||||
// The medical-records backfill always runs here because it is idempotent and a no-op once every Approved registration already has a real stored file, so a database seeded before this feature existed self-heals into habilitado players on the next startup with no config flag to flip.
|
||||
logger.LogInformation("Sample data already present — running the medical-records backfill and skipping the rest.");
|
||||
await medicalRecordSeedBackfiller.BackfillMedicalRecordsAsync(medicalRecordPath);
|
||||
return;
|
||||
}
|
||||
|
||||
seasonCount = Math.Clamp(seasonCount, 1, MaxSeasonCount);
|
||||
playersPerTeam = Math.Clamp(playersPerTeam, MinPlayersPerTeam, MaxPlayersPerTeam);
|
||||
|
||||
List<Venue> venues = BuildVenues();
|
||||
SampleTournamentBuilder.SlugRegistry slugRegistry = new();
|
||||
int playerCounter = 0;
|
||||
|
||||
List<Season> seasons = [];
|
||||
List<SampleTournamentBuilder.BuildResult> allResults = [];
|
||||
|
||||
bool autoDetect = db.ChangeTracker.AutoDetectChangesEnabled;
|
||||
db.ChangeTracker.AutoDetectChangesEnabled = false;
|
||||
|
||||
for (int offset = seasonCount - 1; offset >= 0; offset--)
|
||||
{
|
||||
int number = LatestSeasonNumber - offset;
|
||||
List<SampleTournamentBuilder.BuildResult> results = BuildSeasonTournaments(
|
||||
number, isCurrent: offset == 0, playersPerTeam, venues, slugRegistry, ref playerCounter);
|
||||
|
||||
Season season = new()
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
Name = SeasonName(number),
|
||||
Slug = SlugGenerator.GenerateSlug(SeasonName(number)),
|
||||
Year = SeasonYear(number),
|
||||
};
|
||||
|
||||
foreach (SampleTournamentBuilder.BuildResult result in results)
|
||||
{
|
||||
result.Tournament.Season = season;
|
||||
}
|
||||
|
||||
seasons.Add(season);
|
||||
allResults.AddRange(results);
|
||||
}
|
||||
|
||||
List<Team> allTeams = [.. allResults.SelectMany(r => r.Tournament.Teams)];
|
||||
List<Club> clubs = BuildClubs(allTeams);
|
||||
|
||||
await UploadTeamLogosAsync(allTeams, logosPath);
|
||||
|
||||
foreach (Club club in clubs)
|
||||
{
|
||||
club.LogoUrl = club.Teams.Select(t => t.LogoUrl).FirstOrDefault();
|
||||
}
|
||||
|
||||
db.Venues.AddRange(venues);
|
||||
db.Clubs.AddRange(clubs);
|
||||
db.Seasons.AddRange(seasons);
|
||||
|
||||
foreach (SampleTournamentBuilder.BuildResult result in allResults)
|
||||
{
|
||||
db.Tournaments.Add(result.Tournament);
|
||||
db.PlayerSanctions.AddRange(result.Sanctions);
|
||||
}
|
||||
|
||||
db.BlogPosts.AddRange(BuildBlogPosts());
|
||||
db.TeamStaffs.AddRange(BuildTeamStaff([.. allResults]));
|
||||
db.TeamPointDeductions.AddRange(BuildPointDeductions(allResults));
|
||||
|
||||
db.ChangeTracker.AutoDetectChangesEnabled = autoDetect;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Runs after SaveChangesAsync because TeamId and PlayerId are store-generated, since EntityBase.Id defaults to Guid.Empty, so there is no real object key to build from before this point.
|
||||
await medicalRecordSeedBackfiller.BackfillMedicalRecordsAsync(medicalRecordPath);
|
||||
|
||||
logger.LogInformation(
|
||||
"Sample data seeded: {SeasonCount} seasons ({FirstSeason}–{LastSeason}), {TournamentCount} tournaments, " +
|
||||
"{ClubCount} clubs, {DivisionCount} divisions, {TeamCount} teams, {PlayerCount} players, " +
|
||||
"{MatchCount} matches, {SanctionCount} sanctions.",
|
||||
seasons.Count,
|
||||
seasons[0].Name,
|
||||
seasons[^1].Name,
|
||||
allResults.Count,
|
||||
clubs.Count,
|
||||
allResults.Sum(r => r.Tournament.Divisions.Count),
|
||||
allTeams.Count,
|
||||
allTeams.Sum(t => t.Players.Count),
|
||||
allResults.Sum(r => r.Tournament.Divisions.Sum(d => d.Stages.Sum(s => s.Matches.Count))),
|
||||
allResults.Sum(r => r.Sanctions.Count));
|
||||
}
|
||||
|
||||
private static List<SampleTournamentBuilder.BuildResult> BuildSeasonTournaments(
|
||||
int seasonNumber,
|
||||
bool isCurrent,
|
||||
int playersPerTeam,
|
||||
List<Venue> venues,
|
||||
SampleTournamentBuilder.SlugRegistry slugRegistry,
|
||||
ref int playerCounter)
|
||||
{
|
||||
int year = SeasonYear(seasonNumber);
|
||||
string seasonName = SeasonName(seasonNumber);
|
||||
int seed = (seasonNumber * 7919) + 13;
|
||||
|
||||
ClubSpec[] feminine = Draw(FeminineClubs, seed);
|
||||
ClubSpec[] masculine = Draw(MasculineClubs, seed + 1);
|
||||
|
||||
DateTime aperturaStart = new(year, 3, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime aperturaEnd = new(year, 8, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
SampleTournamentBuilder.TournamentDefinition femenino = new(
|
||||
Name: $"Torneo Apertura Femenino {year}",
|
||||
Description:
|
||||
$"Torneo Apertura Femenino de la Liga Club 12 (Paraná), {seasonName}. " +
|
||||
"Zona única a dos ruedas y Copa de Oro para las siete clasificadas. Finalizado.",
|
||||
TeamRegistrationDeadline: aperturaStart.AddDays(-14),
|
||||
StartDate: aperturaStart,
|
||||
StageStartDate: aperturaStart,
|
||||
StageEndDate: aperturaEnd,
|
||||
Divisions: [ZoneOf("Zona Única", feminine, FemeninoCups)],
|
||||
Status: TournamentStatus.Finished,
|
||||
Category: TournamentCategory.Feminine,
|
||||
RoundRobinLegs: 2,
|
||||
PlayersPerTeam: playersPerTeam,
|
||||
UpsetPercent: UpsetPercent,
|
||||
VarietySeed: seed);
|
||||
|
||||
SampleTournamentBuilder.TournamentDefinition masculino = new(
|
||||
Name: $"Torneo Apertura Masculino {year}",
|
||||
Description:
|
||||
$"Torneo Apertura Masculino de la Liga Club 12 (Paraná), {seasonName}. " +
|
||||
"Tres zonas a dos ruedas con Copa Oro y Copa Plata cada una, más la Copa Cruzada. Finalizado.",
|
||||
TeamRegistrationDeadline: aperturaStart.AddDays(-14),
|
||||
StartDate: aperturaStart,
|
||||
StageStartDate: aperturaStart,
|
||||
StageEndDate: aperturaEnd,
|
||||
Divisions:
|
||||
[
|
||||
ZoneOf("Zona A", [.. masculine[..10]], ZonaABCups),
|
||||
ZoneOf("Zona B", [.. masculine[10..20]], ZonaABCups),
|
||||
ZoneOf("Zona C", [.. masculine[20..]], ZonaCCups),
|
||||
],
|
||||
CrossCup: CopaCruzada,
|
||||
Status: TournamentStatus.Finished,
|
||||
Category: TournamentCategory.Masculine,
|
||||
RoundRobinLegs: 2,
|
||||
PlayersPerTeam: playersPerTeam,
|
||||
UpsetPercent: UpsetPercent,
|
||||
VarietySeed: seed + 2);
|
||||
|
||||
List<SampleTournamentBuilder.BuildResult> results =
|
||||
[
|
||||
SampleTournamentBuilder.Build(femenino, venues, ref playerCounter, includePlayoffs: true, slugRegistry),
|
||||
SampleTournamentBuilder.Build(masculino, venues, ref playerCounter, includePlayoffs: true, slugRegistry),
|
||||
];
|
||||
|
||||
if (!isCurrent)
|
||||
{
|
||||
return results;
|
||||
}
|
||||
|
||||
DateTime clausuraStart = new(year, 8, 2, 0, 0, 0, DateTimeKind.Utc);
|
||||
DateTime clausuraEnd = new(year, 12, 20, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
ClubSpec[] clausuraMasculine = Draw(MasculineClubs, seed + 3);
|
||||
ClubSpec[] clausuraFeminine = Draw(FeminineClubs, seed + 4);
|
||||
|
||||
SampleTournamentBuilder.TournamentDefinition clausuraMasculino = new(
|
||||
Name: $"Torneo Clausura Masculino {year}",
|
||||
Description:
|
||||
$"Torneo Clausura Masculino de la Liga Club 12 (Paraná), {seasonName}. " +
|
||||
$"En disputa: {ClausuraPlayedRounds} jornadas jugadas, el resto ya programadas.",
|
||||
TeamRegistrationDeadline: clausuraStart.AddDays(-14),
|
||||
StartDate: clausuraStart,
|
||||
StageStartDate: clausuraStart,
|
||||
StageEndDate: clausuraEnd,
|
||||
Divisions:
|
||||
[
|
||||
ZoneOf("Zona A", [.. clausuraMasculine[..8]], null),
|
||||
ZoneOf("Zona B", [.. clausuraMasculine[8..16]], null),
|
||||
],
|
||||
Status: TournamentStatus.Ongoing,
|
||||
Category: TournamentCategory.Masculine,
|
||||
RoundRobinLegs: 2,
|
||||
PlayedRoundsPerZone: ClausuraPlayedRounds,
|
||||
PlayersPerTeam: playersPerTeam,
|
||||
UpsetPercent: UpsetPercent,
|
||||
VarietySeed: seed + 5);
|
||||
|
||||
SampleTournamentBuilder.TournamentDefinition clausuraFemenino = new(
|
||||
Name: $"Torneo Clausura Femenino {year}",
|
||||
Description:
|
||||
$"Torneo Clausura Femenino de la Liga Club 12 (Paraná), {seasonName}. " +
|
||||
$"En disputa: {ClausuraPlayedRounds} jornadas jugadas, el resto ya programadas.",
|
||||
TeamRegistrationDeadline: clausuraStart.AddDays(-14),
|
||||
StartDate: clausuraStart,
|
||||
StageStartDate: clausuraStart,
|
||||
StageEndDate: clausuraEnd,
|
||||
Divisions: [ZoneOf("Zona Única", [.. clausuraFeminine[..6]], null)],
|
||||
Status: TournamentStatus.Ongoing,
|
||||
Category: TournamentCategory.Feminine,
|
||||
RoundRobinLegs: 2,
|
||||
PlayedRoundsPerZone: ClausuraPlayedRounds,
|
||||
PlayersPerTeam: playersPerTeam,
|
||||
UpsetPercent: UpsetPercent,
|
||||
VarietySeed: seed + 6);
|
||||
|
||||
results.Add(SampleTournamentBuilder.Build(
|
||||
clausuraMasculino, venues, ref playerCounter, includePlayoffs: false, slugRegistry));
|
||||
results.Add(SampleTournamentBuilder.Build(
|
||||
clausuraFemenino, venues, ref playerCounter, includePlayoffs: false, slugRegistry));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static SampleTournamentBuilder.DivisionDefinition ZoneOf(
|
||||
string name, ClubSpec[] clubs, SampleTournamentBuilder.PlayoffCupDefinition[]? cups) =>
|
||||
new(name,
|
||||
[.. clubs.Select(c => c.Name)],
|
||||
[.. clubs.Select(c => c.Code)],
|
||||
[.. clubs.Select(c => c.Color)],
|
||||
cups,
|
||||
TeamStyles: [.. clubs.Select(c => c.Style)],
|
||||
TeamSecondaryColors: [.. clubs.Select(c => c.Secondary)]);
|
||||
|
||||
/// <summary>
|
||||
/// Re-sorts the club pool deterministically to produce one season's draw.
|
||||
/// </summary>
|
||||
private static ClubSpec[] Draw(ClubSpec[] clubs, int seed)
|
||||
{
|
||||
int[] order = [.. Enumerable.Range(0, clubs.Length)];
|
||||
Shuffle(order, new Random(seed));
|
||||
|
||||
return [.. order.Select(i => clubs[i])];
|
||||
}
|
||||
|
||||
private static string SeasonName(int number) => $"Temporada {ToRoman(number)}";
|
||||
|
||||
private static int SeasonYear(int number) => LatestSeasonYear - (LatestSeasonNumber - number);
|
||||
|
||||
private static string ToRoman(int number)
|
||||
{
|
||||
(int Value, string Symbol)[] map =
|
||||
[
|
||||
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"),
|
||||
(50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
|
||||
];
|
||||
|
||||
StringBuilder roman = new();
|
||||
foreach ((int value, string symbol) in map)
|
||||
{
|
||||
while (number >= value)
|
||||
{
|
||||
roman.Append(symbol);
|
||||
number -= value;
|
||||
}
|
||||
}
|
||||
|
||||
return roman.ToString();
|
||||
}
|
||||
|
||||
private static List<Club> BuildClubs(IReadOnlyList<Team> teams)
|
||||
{
|
||||
Dictionary<string, Club> bySlug = [];
|
||||
List<Club> clubs = [];
|
||||
|
||||
foreach (Team team in teams)
|
||||
{
|
||||
string slug = SlugGenerator.GenerateSlug(team.Name);
|
||||
|
||||
if (!bySlug.TryGetValue(slug, out Club? club))
|
||||
{
|
||||
club = new Club
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
Name = team.Name,
|
||||
Slug = slug,
|
||||
};
|
||||
bySlug[slug] = club;
|
||||
clubs.Add(club);
|
||||
}
|
||||
|
||||
team.Club = club;
|
||||
club.Teams.Add(team);
|
||||
}
|
||||
|
||||
return clubs;
|
||||
}
|
||||
|
||||
private static List<TeamPointDeduction> BuildPointDeductions(
|
||||
IReadOnlyList<SampleTournamentBuilder.BuildResult> results)
|
||||
{
|
||||
(int Points, string Reason)[] specs =
|
||||
[
|
||||
(2, "Incomparecencia en la jornada 4."),
|
||||
(1, "Inclusión de un jugador no habilitado."),
|
||||
(3, "Incidentes de la parcialidad local."),
|
||||
(1, "Presentación tardía de la planilla."),
|
||||
];
|
||||
|
||||
// Prefers ongoing tournaments and the newest season first so a penalty lands where the standings table is actually visible, and avoids ranking a finished zone's bracket differently from the cup it fed since that bracket was seeded from standings without deductions.
|
||||
List<(Division Division, List<Team> Teams)> candidates =
|
||||
[
|
||||
.. results
|
||||
.Select((result, index) => (result, index))
|
||||
.OrderBy(entry => entry.result.Tournament.Status == TournamentStatus.Ongoing ? 0 : 1)
|
||||
.ThenByDescending(entry => entry.index)
|
||||
.SelectMany(entry => entry.result.Tournament.Divisions)
|
||||
.Where(d => !d.IsCrossDivisionCup)
|
||||
.Select(d => (
|
||||
Division: d,
|
||||
Teams: d.Stages
|
||||
.Where(s => s.StageType == StageType.Group)
|
||||
.SelectMany(s => s.StageTeamMatches)
|
||||
.Select(stm => stm.Team)
|
||||
.OfType<Team>()
|
||||
.ToList()))
|
||||
.Where(pair => pair.Teams.Count > 3),
|
||||
];
|
||||
|
||||
List<TeamPointDeduction> deductions = [];
|
||||
|
||||
for (int i = 0; i < specs.Length && i < candidates.Count; i++)
|
||||
{
|
||||
(Division division, List<Team> teams) = candidates[i];
|
||||
|
||||
deductions.Add(new TeamPointDeduction
|
||||
{
|
||||
CreatedBy = AuditConstants.SystemUser,
|
||||
Division = division,
|
||||
Team = teams[^(1 + (i % teams.Count))],
|
||||
Points = specs[i].Points,
|
||||
Reason = specs[i].Reason,
|
||||
});
|
||||
}
|
||||
|
||||
return deductions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes existing sample domain data in FK-safe order so a reseed starts from a clean slate.
|
||||
/// </summary>
|
||||
private async Task ResetSeededDataAsync()
|
||||
{
|
||||
await db.Scorers.ExecuteDeleteAsync();
|
||||
await db.PlayersStatistics.ExecuteDeleteAsync();
|
||||
await db.PlayerSanctions.ExecuteDeleteAsync();
|
||||
await db.TeamStaffs.ExecuteDeleteAsync();
|
||||
await db.StageTeamMatches.ExecuteDeleteAsync();
|
||||
await db.PlayerTeamRegistrations.ExecuteDeleteAsync();
|
||||
await db.TeamTournamentRegistrations.ExecuteDeleteAsync();
|
||||
// Matches carry the optional SeriesId FK, so they go before MatchSeries.
|
||||
await db.Matches.ExecuteDeleteAsync();
|
||||
await db.MatchSeries.ExecuteDeleteAsync();
|
||||
await db.Players.ExecuteDeleteAsync();
|
||||
await db.DivisionPlayoffMappings.ExecuteDeleteAsync();
|
||||
await db.TeamPointDeductions.ExecuteDeleteAsync();
|
||||
await db.Stages.ExecuteDeleteAsync();
|
||||
await db.Teams.ExecuteDeleteAsync();
|
||||
await db.Clubs.ExecuteDeleteAsync();
|
||||
await db.Divisions.ExecuteDeleteAsync();
|
||||
await db.Tournaments.ExecuteDeleteAsync();
|
||||
await db.Seasons.ExecuteDeleteAsync();
|
||||
await db.Venues.ExecuteDeleteAsync();
|
||||
await db.BlogPosts.ExecuteDeleteAsync();
|
||||
|
||||
logger.LogInformation("Seed reset: existing sample domain data deleted before reseeding.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a real PNG crest per team from logosPath, replacing each team's generated SVG crest.
|
||||
/// </summary>
|
||||
private async Task UploadTeamLogosAsync(IReadOnlyList<Team> teams, string? logosPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(logosPath))
|
||||
{
|
||||
logger.LogInformation("Seed:LogosPath not configured — keeping the generated team crests.");
|
||||
return;
|
||||
}
|
||||
|
||||
string[] files;
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(logosPath))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Seed logos path '{Path}' not found — keeping the generated team crests.", logosPath);
|
||||
return;
|
||||
}
|
||||
|
||||
files = Directory.GetFiles(logosPath, "*.png");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex, "Could not read seed logos from '{Path}' — keeping the generated team crests.", logosPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.Length == 0)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"No PNG logos found in '{Path}' — keeping the generated team crests.", logosPath);
|
||||
return;
|
||||
}
|
||||
|
||||
int[] order = [.. Enumerable.Range(0, files.Length)];
|
||||
Shuffle(order, new Random(LogoShuffleSeed));
|
||||
|
||||
List<IGrouping<string, Team>> byClub = [.. teams.GroupBy(t => t.Name)];
|
||||
|
||||
int uploaded = 0;
|
||||
for (int i = 0; i < byClub.Count; i++)
|
||||
{
|
||||
string file = files[order[i % files.Length]];
|
||||
try
|
||||
{
|
||||
await using FileStream stream = File.OpenRead(file);
|
||||
string url = await supabaseHelper.UploadImageAsync<Team>(stream, Path.GetFileName(file));
|
||||
|
||||
foreach (Team team in byClub[i])
|
||||
{
|
||||
team.LogoUrl = url;
|
||||
uploaded++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex, "Failed to upload logo '{File}' for club '{Club}' — keeping its generated crest.",
|
||||
file, byClub[i].Key);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Uploaded {ClubCount} real crests from '{Path}', applied to {Uploaded}/{Total} teams.",
|
||||
byClub.Count, logosPath, uploaded, teams.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-place Fisher-Yates shuffle with a caller-provided RNG.
|
||||
/// </summary>
|
||||
private static void Shuffle(int[] values, Random random)
|
||||
{
|
||||
for (int i = values.Length - 1; i > 0; i--)
|
||||
{
|
||||
int j = random.Next(i + 1);
|
||||
(values[i], values[j]) = (values[j], values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// 8 plausible Argentine coach and assistant name pairs, cycled by team index so every seeded team gets one DT and one Asistente for its current tournament.
|
||||
private static readonly string[] CoachFirstNames =
|
||||
["Carlos", "Fernando", "Miguel", "Diego", "Sergio", "Pablo", "Martín", "Gustavo"];
|
||||
private static readonly string[] CoachLastNames =
|
||||
["Gómez", "Rodríguez", "Fernández", "Sosa", "Benítez", "Acosta", "Ibarra", "Peralta"];
|
||||
private static readonly string[] AssistantFirstNames =
|
||||
["Javier", "Alejandro", "Nicolás", "Ezequiel", "Federico", "Rodrigo", "Matías", "Emiliano"];
|
||||
private static readonly string[] AssistantLastNames =
|
||||
["Coronel", "Duarte", "Aguirre", "Medina", "Bordón", "Cabrera", "Zabala", "Leguizamón"];
|
||||
|
||||
/// <summary>
|
||||
/// Builds a DT and Asistente staff pair for every seeded team, scoped to the tournament it was built for.
|
||||
/// </summary>
|
||||
private static List<TeamStaff> BuildTeamStaff(SampleTournamentBuilder.BuildResult[] results)
|
||||
{
|
||||
List<TeamStaff> staff = [];
|
||||
int index = 0;
|
||||
|
||||
IEnumerable<(Team Team, Tournament Tournament)> teamsByTournament = results
|
||||
.SelectMany(result => result.Tournament.Teams.Select(team => (team, result.Tournament)));
|
||||
|
||||
foreach ((Team team, Tournament tournament) in teamsByTournament)
|
||||
{
|
||||
string coachName =
|
||||
$"{CoachFirstNames[index % CoachFirstNames.Length]} {CoachLastNames[index % CoachLastNames.Length]}";
|
||||
string assistantName =
|
||||
$"{AssistantFirstNames[index % AssistantFirstNames.Length]} {AssistantLastNames[index % AssistantLastNames.Length]}";
|
||||
|
||||
staff.Add(new TeamStaff
|
||||
{
|
||||
CreatedBy = Domain.Constants.AuditConstants.SystemUser,
|
||||
Team = team,
|
||||
Tournament = tournament,
|
||||
FullName = coachName,
|
||||
Role = TeamStaffRole.Coach,
|
||||
});
|
||||
staff.Add(new TeamStaff
|
||||
{
|
||||
CreatedBy = Domain.Constants.AuditConstants.SystemUser,
|
||||
Team = team,
|
||||
Tournament = tournament,
|
||||
FullName = assistantName,
|
||||
Role = TeamStaffRole.AssistantCoach,
|
||||
});
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
return staff;
|
||||
}
|
||||
|
||||
private static List<Venue> BuildVenues()
|
||||
{
|
||||
// Real basketball club gyms from Paraná, Entre Ríos, with approximate coordinates around the city near -31.73, -60.52.
|
||||
(string Name, string Address, double Latitude, double Longitude)[] specs =
|
||||
[
|
||||
("Estadio Ángel Malvicino (Echagüe)", "Av. Almafuerte, Paraná", -31.7398, -60.5060),
|
||||
("Gimnasio Estudiantes de Paraná", "Gualeguaychú 100, Paraná", -31.7255, -60.5150),
|
||||
("Paraná Rowing Club", "Av. Costanera, Paraná", -31.7150, -60.4890),
|
||||
("Club Sionista", "25 de Mayo, Paraná", -31.7345, -60.5250),
|
||||
("Club Atlético Talleres (Paraná)", "Av. Ramírez, Paraná", -31.7460, -60.5300),
|
||||
("Polideportivo Municipal Paraná", "Parque Urquiza, Paraná", -31.7205, -60.5050),
|
||||
("Gimnasio Central Entrerriano", "Av. Ramírez 2100, Paraná", -31.7502, -60.5188),
|
||||
("Club Recreativo Paraná", "Sarmiento 800, Paraná", -31.7310, -60.5215),
|
||||
("Club Olimpia (Paraná)", "Bertozzi 1500, Paraná", -31.7415, -60.5402),
|
||||
("Club Bancario", "Córdoba 400, Paraná", -31.7290, -60.5285),
|
||||
("Gimnasio Parque Sur", "Av. Zanni, Paraná", -31.7562, -60.5121),
|
||||
("Club Neptunia", "Av. de las Américas, Paraná", -31.7480, -60.5480),
|
||||
("Estadio Ciudad de Concordia", "Av. Monseñor Rösch, Concordia", -31.3930, -58.0209),
|
||||
("Gimnasio Rocamora", "25 de Mayo 200, Concepción del Uruguay", -32.4835, -58.2320),
|
||||
];
|
||||
|
||||
List<Venue> venues = [];
|
||||
foreach ((string name, string address, double latitude, double longitude) in specs)
|
||||
{
|
||||
venues.Add(new Venue
|
||||
{
|
||||
CreatedBy = Domain.Constants.AuditConstants.SystemUser,
|
||||
Name = name,
|
||||
Slug = Application.Utils.Helper.Slug.SlugGenerator.GenerateSlug(name),
|
||||
Address = address,
|
||||
PhotoUrl = SampleArtwork.VenuePhotoDataUri(name),
|
||||
Latitude = latitude,
|
||||
Longitude = longitude,
|
||||
});
|
||||
}
|
||||
|
||||
return venues;
|
||||
}
|
||||
|
||||
private static List<BlogPost> BuildBlogPosts()
|
||||
{
|
||||
(string Title, string Body)[] posts =
|
||||
[
|
||||
(
|
||||
"La Temporada XXV ya tiene campeones",
|
||||
"Se cerró el Apertura de la Liga Club 12 (Paraná) en las categorías masculina y " +
|
||||
"femenina. Con la Copa de Oro femenina, las seis copas de las Zonas A, B y C, y la Copa " +
|
||||
"Cruzada masculina ya definidas, conocemos a los campeones de la temporada. Mirá el podio " +
|
||||
"completo en la sección Campeones."
|
||||
),
|
||||
(
|
||||
"Torneo Apertura Masculino: así quedaron las Zonas A, B y C",
|
||||
"El Torneo Apertura Masculino se jugó en tres zonas — A y B de 10 equipos, C de 13 — todas " +
|
||||
"contra todos a ida y vuelta. Los primeros cuatro de cada zona avanzaron a la Copa Oro y " +
|
||||
"el resto a la Copa Plata. Repasá las tablas finales y las llaves de playoffs."
|
||||
),
|
||||
(
|
||||
"Copa Cruzada: la copa cruzada de la temporada",
|
||||
"La Copa Cruzada masculina reunió a equipos de las tres zonas en seis grupos (ida y " +
|
||||
"vuelta), con los dos primeros de cada grupo clasificando a una llave combinada de 12 " +
|
||||
"equipos. Los partidos de la copa se disputan entre semana para no superponerse con las " +
|
||||
"zonas del fin de semana."
|
||||
),
|
||||
(
|
||||
"Arrancó el Clausura: así se juega la segunda mitad del año",
|
||||
"Con las zonas del Clausura ya en marcha — dos zonas masculinas de ocho equipos y una zona " +
|
||||
"femenina de seis, todas ida y vuelta — la Liga Club 12 encara la segunda mitad del año. " +
|
||||
"Las primeras jornadas ya tienen resultado y el resto del fixture está publicado, así que " +
|
||||
"podés seguir la tabla en vivo y ver cuándo juega tu equipo."
|
||||
),
|
||||
(
|
||||
"Los clubes de la liga, temporada por temporada",
|
||||
"Cada club de la Liga Club 12 tiene ahora su ficha propia: el historial completo de las " +
|
||||
"temporadas que disputó, los planteles de cada año y los torneos en los que participó. " +
|
||||
"Es la forma más rápida de ver de dónde viene cada institución más allá de la tabla de " +
|
||||
"la temporada en curso."
|
||||
),
|
||||
(
|
||||
"Fichas médicas: qué necesitás para estar habilitado",
|
||||
"Un jugador queda habilitado cuando su ficha médica está aprobada y el archivo cargado en " +
|
||||
"el sistema. Desde el panel, cada delegado puede subir la ficha de sus jugadores y seguir " +
|
||||
"el estado de la revisión. Los jugadores con ficha pendiente aparecen marcados en el " +
|
||||
"plantel para que nadie llegue al partido con una sorpresa."
|
||||
),
|
||||
(
|
||||
"Sanciones y descuentos de puntos: cómo se aplican",
|
||||
"El tribunal de disciplina publica las sanciones a jugadores y clubes junto con la " +
|
||||
"cantidad de fechas de suspensión y el estado de cada apelación. Cuando la sanción es " +
|
||||
"institucional puede incluir un descuento de puntos, que se resta directamente del total " +
|
||||
"del equipo en la tabla de posiciones de su zona."
|
||||
),
|
||||
(
|
||||
"Goleadores: quiénes lideran la tabla de anotadores",
|
||||
"La tabla de goleadores se arma con las planillas de cada partido, así que se actualiza " +
|
||||
"apenas se carga un resultado. Podés filtrarla por torneo y por zona para ver quién " +
|
||||
"lidera en cada categoría de la temporada."
|
||||
),
|
||||
];
|
||||
|
||||
List<BlogPost> blogPosts = [];
|
||||
|
||||
foreach ((string title, string body) in posts)
|
||||
{
|
||||
blogPosts.Add(new BlogPost
|
||||
{
|
||||
CreatedBy = Domain.Constants.AuditConstants.SystemUser,
|
||||
Author = "Redacción Club 12",
|
||||
Title = title,
|
||||
Slug = Application.Utils.Helper.Slug.SlugGenerator.GenerateSlug(title),
|
||||
PhotoUrl = SampleArtwork.BlogCoverDataUri(title),
|
||||
MarkdownText = body,
|
||||
Views = 0,
|
||||
});
|
||||
}
|
||||
|
||||
return blogPosts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using Application.Interfaces.Storage;
|
||||
|
||||
using Domain.Constants;
|
||||
using Domain.Entities.Models;
|
||||
using Domain.Enums;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <summary>
|
||||
/// Backfills a real, stored medical record file for every seeded Approved registration that lacks one.
|
||||
/// </summary>
|
||||
public sealed class MedicalRecordSeedBackfiller(
|
||||
ApplicationDBContext db,
|
||||
ILogger<MedicalRecordSeedBackfiller> logger,
|
||||
IMedicalRecordStorage medicalRecordStorage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Embedded resource name of the generic ficha medica shipped inside the assembly.
|
||||
/// </summary>
|
||||
private const string EmbeddedMedicalRecordResourceName =
|
||||
"Infrastructure.Persistance.Seeding.Assets.ficha-medica-generica.pdf";
|
||||
|
||||
/// <summary>
|
||||
/// File name recorded for the seeded ficha medica, whether it came from the embedded resource or the last-resort generated placeholder.
|
||||
/// </summary>
|
||||
private const string PlaceholderMedicalRecordFileName = "ficha-medica-generica.pdf";
|
||||
|
||||
// Flushes progress every N uploaded rows so an interruption loses at most this many refs and the step stays resumable.
|
||||
private const int MedicalRecordSaveBatchSize = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Uploads a real medical PDF for every Approved registration whose file reference is missing or legacy.
|
||||
/// </summary>
|
||||
public async Task BackfillMedicalRecordsAsync(string? medicalRecordPath)
|
||||
{
|
||||
bool isConfigured = !string.IsNullOrWhiteSpace(medicalRecordPath);
|
||||
|
||||
byte[] pdf;
|
||||
string fileName;
|
||||
if (isConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(medicalRecordPath))
|
||||
{
|
||||
// An explicitly configured path that is not there is a misconfiguration, so warn and skip rather than papering over it with the generic one.
|
||||
logger.LogWarning(
|
||||
"Seed medical-record file '{Path}' not found — skipping medical-record seeding.",
|
||||
medicalRecordPath);
|
||||
return;
|
||||
}
|
||||
|
||||
pdf = await File.ReadAllBytesAsync(medicalRecordPath!);
|
||||
fileName = Path.GetFileName(medicalRecordPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not read seed medical record from '{Path}' — skipping.", medicalRecordPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Falling back to the generic ficha médica keeps the seeded league coherent, since without a real stored file every Approved registration reads as not habilitado while the same players hold scorer and statistic rows for thousands of played matches, the exact combination PlayerStatisticService rejects on a real match sheet.
|
||||
pdf = LoadEmbeddedMedicalRecordPdf();
|
||||
fileName = PlaceholderMedicalRecordFileName;
|
||||
logger.LogInformation(
|
||||
"No Seed:MedicalRecordPath configured — seeding the built-in generic ficha médica so "
|
||||
+ "approved registrations end up habilitado.");
|
||||
}
|
||||
|
||||
// Superset filter kept EF-translatable: StartsWith on a constant translates to a SQL LIKE clause.
|
||||
// The per-row IsStoredReference check below is the authoritative skip-vs-upload decision, the same predicate the read sites and the approve-time write guard use, so the three can never drift.
|
||||
// The CreatedBy == SystemUser branch lets a seeder-created Pending or Rejected row self-heal too, safe because that value is never written by a real admin action, only by the seeder itself.
|
||||
List<PlayerTeamRegistration> candidates = await db.PlayerTeamRegistrations
|
||||
.Where(r => (r.MedicalRecordStatus == MedicalRecordStatus.Approved
|
||||
|| r.CreatedBy == AuditConstants.SystemUser)
|
||||
&& (r.MedicalRecordFileUrl == null
|
||||
|| r.MedicalRecordFileUrl == ""
|
||||
|| r.MedicalRecordFileUrl.StartsWith(PlayerTeamRegistration.LegacyReferencePrefix)))
|
||||
.ToListAsync();
|
||||
|
||||
int uploaded = 0;
|
||||
int failed = 0;
|
||||
int pending = 0;
|
||||
foreach (PlayerTeamRegistration registration in candidates)
|
||||
{
|
||||
if (PlayerTeamRegistration.IsStoredReference(registration.MedicalRecordFileUrl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using MemoryStream content = new(pdf, writable: false);
|
||||
string objectPath = await medicalRecordStorage.StoreAsync(
|
||||
registration.TeamId, registration.PlayerId, fileName, content);
|
||||
|
||||
registration.MedicalRecordFileUrl = objectPath;
|
||||
registration.MedicalRecordFileName = fileName;
|
||||
if (registration.MedicalRecordStatus != MedicalRecordStatus.Approved)
|
||||
{
|
||||
registration.MedicalRecordStatus = MedicalRecordStatus.Approved;
|
||||
registration.MedicalRecordReviewReason = null;
|
||||
registration.MedicalRecordReviewedAt = DateTime.UtcNow;
|
||||
}
|
||||
uploaded++;
|
||||
pending++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
logger.LogWarning(ex,
|
||||
"Failed to upload the seed medical record for player {PlayerId} / team {TeamId} — leaving it without a file.",
|
||||
registration.PlayerId, registration.TeamId);
|
||||
}
|
||||
|
||||
if (pending >= MedicalRecordSaveBatchSize)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
pending = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (pending > 0)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Medical-record seed: {Uploaded} uploaded, {Failed} failed, {Total} candidates, from '{Path}'.",
|
||||
uploaded, failed, candidates.Count, medicalRecordPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the generic ficha medica embedded in the assembly, named by EmbeddedMedicalRecordResourceName.
|
||||
/// </summary>
|
||||
private static byte[] LoadEmbeddedMedicalRecordPdf()
|
||||
{
|
||||
try
|
||||
{
|
||||
using Stream? stream = typeof(MedicalRecordSeedBackfiller).Assembly
|
||||
.GetManifestResourceStream(EmbeddedMedicalRecordResourceName);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
return BuildPlaceholderMedicalRecordPdf();
|
||||
}
|
||||
|
||||
using MemoryStream buffer = new();
|
||||
stream.CopyTo(buffer);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return BuildPlaceholderMedicalRecordPdf();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A real, valid one-page PDF built in memory, with a correct cross-reference table and offsets.
|
||||
/// </summary>
|
||||
private static byte[] BuildPlaceholderMedicalRecordPdf()
|
||||
{
|
||||
const string content =
|
||||
"BT /F1 16 Tf 60 760 Td (Ficha medica de ejemplo - Liga Club 12) Tj ET";
|
||||
|
||||
string[] bodies =
|
||||
[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
|
||||
+ "/Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
$"<< /Length {content.Length} >>\nstream\n{content}\nendstream",
|
||||
];
|
||||
|
||||
StringBuilder pdf = new("%PDF-1.4\n");
|
||||
List<int> offsets = [];
|
||||
|
||||
for (int i = 0; i < bodies.Length; i++)
|
||||
{
|
||||
// Every character written here is ASCII, so the builder's length is also the byte offset the xref table has to point at.
|
||||
offsets.Add(pdf.Length);
|
||||
pdf.Append(i + 1).Append(" 0 obj\n").Append(bodies[i]).Append("\nendobj\n");
|
||||
}
|
||||
|
||||
int xrefOffset = pdf.Length;
|
||||
pdf.Append("xref\n0 ").Append(bodies.Length + 1).Append("\n")
|
||||
.Append("0000000000 65535 f \n");
|
||||
|
||||
foreach (int offset in offsets)
|
||||
{
|
||||
pdf.Append(offset.ToString("D10", CultureInfo.InvariantCulture)).Append(" 00000 n \n");
|
||||
}
|
||||
|
||||
pdf.Append("trailer\n<< /Size ").Append(bodies.Length + 1).Append(" /Root 1 0 R >>\n")
|
||||
.Append("startxref\n").Append(xrefOffset).Append("\n%%EOF\n");
|
||||
|
||||
return Encoding.ASCII.GetBytes(pdf.ToString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace Infrastructure.Persistance;
|
||||
|
||||
/// <summary>
|
||||
/// Generates the sample dataset's artwork as self-contained SVG data URIs.
|
||||
/// </summary>
|
||||
internal static class SampleArtwork
|
||||
{
|
||||
/// <summary>
|
||||
/// Widest a generated data URI may get, matching the 2048 character cap on BlogPost.PhotoUrl in its EF configuration.
|
||||
/// </summary>
|
||||
public const int MaxDataUriLength = 2048;
|
||||
|
||||
// Court floors: plausible hardwood tones, picked by the venue's name.
|
||||
private static readonly string[] Floors = ["#B4763A", "#C08A4A", "#A9682F", "#CE9856"];
|
||||
|
||||
// Painted key colours, likewise per venue.
|
||||
private static readonly string[] Keys = ["#1D4ED8", "#B91C1C", "#0F766E", "#7C3AED", "#C2410C"];
|
||||
|
||||
// Cover gradients: pairs dark enough for white text in both stops.
|
||||
private static readonly (string From, string To)[] CoverGradients =
|
||||
[
|
||||
("#0F172A", "#1D4ED8"), ("#7C2D12", "#C2410C"), ("#134E4A", "#0F766E"),
|
||||
("#3B0764", "#7C3AED"), ("#450A0A", "#B91C1C"), ("#082F49", "#0284C7"),
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// A stylised plan of the venue's court, with hardwood floor, sidelines, centre circle, and both painted keys.
|
||||
/// </summary>
|
||||
public static string VenuePhotoDataUri(string venueName)
|
||||
{
|
||||
// Floor and key are read from different slices of the hash because taking the second from the same low bits as the first correlated them, causing the 14 seeded gyms to land on only 9 of the 20 possible combinations.
|
||||
int hash = StableHash(venueName);
|
||||
string floor = Floors[hash % Floors.Length];
|
||||
string key = Keys[(hash >> 8) % Keys.Length];
|
||||
|
||||
string svg =
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 360' width='640' height='360'>"
|
||||
+ $"<rect width='640' height='360' fill='{floor}'/>"
|
||||
+ $"<rect x='24' y='106' width='108' height='148' fill='{key}' fill-opacity='0.85'/>"
|
||||
+ $"<rect x='508' y='106' width='108' height='148' fill='{key}' fill-opacity='0.85'/>"
|
||||
+ "<g fill='none' stroke='#FFFFFF' stroke-width='4'>"
|
||||
+ "<rect x='24' y='24' width='592' height='312'/>"
|
||||
+ "<path d='M320 24v312'/>"
|
||||
+ "<circle cx='320' cy='180' r='46'/>"
|
||||
+ "<rect x='24' y='106' width='108' height='148'/>"
|
||||
+ "<rect x='508' y='106' width='108' height='148'/>"
|
||||
+ "<path d='M132 106a86 74 0 0 1 0 148'/>"
|
||||
+ "<path d='M508 106a86 74 0 0 0 0 148'/>"
|
||||
+ "</g>"
|
||||
+ "</svg>";
|
||||
|
||||
return ToDataUri(svg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A 1200 by 630 social-card cover banner for a post, with a gradient chosen from postSlug, a ball outline, and the league's name.
|
||||
/// </summary>
|
||||
public static string BlogCoverDataUri(string postSlug)
|
||||
{
|
||||
(string from, string to) = CoverGradients[StableHash(postSlug) % CoverGradients.Length];
|
||||
|
||||
string svg =
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 630' width='1200' height='630'>"
|
||||
+ "<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>"
|
||||
+ $"<stop offset='0' stop-color='{from}'/><stop offset='1' stop-color='{to}'/>"
|
||||
+ "</linearGradient></defs>"
|
||||
+ "<rect width='1200' height='630' fill='url(#g)'/>"
|
||||
+ "<g fill='none' stroke='#FFFFFF' stroke-opacity='0.34' stroke-width='10'>"
|
||||
+ "<circle cx='980' cy='430' r='190'/>"
|
||||
+ "<path d='M790 430h380M980 240v380M846 296q134 134 0 268M1114 296q-134 134 0 268'/>"
|
||||
+ "</g>"
|
||||
+ "<text x='72' y='128' font-family='Helvetica,Arial,sans-serif' font-size='46' "
|
||||
+ "font-weight='700' fill='#FFFFFF' fill-opacity='0.88'>LIGA CLUB 12</text>"
|
||||
+ "</svg>";
|
||||
|
||||
return ToDataUri(svg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Base64 data URI for an SVG document.
|
||||
/// </summary>
|
||||
private static string ToDataUri(string svg) =>
|
||||
"data:image/svg+xml;base64," + Convert.ToBase64String(Encoding.UTF8.GetBytes(svg));
|
||||
|
||||
/// <summary>
|
||||
/// Small, stable, non-negative hash of a string.
|
||||
/// </summary>
|
||||
private static int StableHash(string value)
|
||||
{
|
||||
int hash = 17;
|
||||
foreach (char character in value)
|
||||
{
|
||||
hash = (hash * 31) + character;
|
||||
}
|
||||
|
||||
return hash & 0x7FFFFFFF;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user