Fix all 45 audited bugs from bug-fixes.md

Verified every finding against the code, then fixed correctness (redirects,
advancement reset, duplicate heats, high-jump attempt entry, placement
ranking with tie handling, delete-dependency 500s), security (secrets out of
config, config-driven admin seed, login lockout, role authorization with
school scoping, heat-time IDOR, forwarded headers, last-admin guards),
schema integrity (nullable+filtered ExistingStudentId, unique indexes for
rounds/heats/bar heights via SchemaIntegrityFixes migration), performance
(N+1 removal in high jump/reports/standings/dashboard, SQL-side student
paging), and hygiene (duplicate notifications, auto-dismiss scope, local
bootstrap-icons, orphaned files, test-data.sql tournament creation).

FluentValidation is now registered; AutoMapper bumped to 14.0.0 (advisory
fully patched only in licence-changed 15.1.1 — documented). 11 new tests;
63/63 passing. Credential rotation and deploy-time DB_CONNECTION_STRING are
required manual follow-ups, documented in bug-fixes.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 08:30:53 -04:00
parent 78c526ee35
commit 810f721e48
93 changed files with 3251 additions and 1606 deletions

View File

@@ -1,2 +1,3 @@
namespace SportsDivision.Application.DTOs;
public class PlacementPointConfigDto { public int PlacementPointConfigId { get; set; } public int Placement { get; set; } public int Points { get; set; } }
public class PlacementPointConfigCreateDto { public int Placement { get; set; } public int Points { get; set; } }

View File

@@ -1,3 +1,4 @@
namespace SportsDivision.Application.DTOs;
public class ScoringConstantDto { public int ScoringConstantId { get; set; } public int EventId { get; set; } public string EventName { get; set; } = string.Empty; public decimal A { get; set; } public decimal B { get; set; } public decimal C { get; set; } public string Unit { get; set; } = string.Empty; }
public class ScoringConstantUpdateDto { public int ScoringConstantId { get; set; } public decimal A { get; set; } public decimal B { get; set; } public decimal C { get; set; } }
public class ScoringConstantCreateDto { public int EventId { get; set; } public decimal A { get; set; } public decimal B { get; set; } public decimal C { get; set; } public string Unit { get; set; } = string.Empty; }

View File

@@ -1,5 +1,5 @@
using SportsDivision.Domain.Enums;
namespace SportsDivision.Application.DTOs;
public class StudentDto { public int StudentId { get; set; } public string ExistingStudentId { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public string FullName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } public Sex Sex { get; set; } public int SchoolId { get; set; } public string SchoolName { get; set; } = string.Empty; public bool IsActive { get; set; } public int? Age { get; set; } }
public class StudentCreateDto { public string ExistingStudentId { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } public Sex Sex { get; set; } public int SchoolId { get; set; } }
public class StudentDto { public int StudentId { get; set; } public string? ExistingStudentId { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public string FullName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } public Sex Sex { get; set; } public int SchoolId { get; set; } public string SchoolName { get; set; } = string.Empty; public bool IsActive { get; set; } public int? Age { get; set; } }
public class StudentCreateDto { public string? ExistingStudentId { get; set; } public string FirstName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty; public DateOnly DateOfBirth { get; set; } public Sex Sex { get; set; } public int SchoolId { get; set; } }
public class StudentUpdateDto : StudentCreateDto { public int StudentId { get; set; } public bool IsActive { get; set; } }

View File

@@ -1,3 +1,4 @@
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using SportsDivision.Application.Interfaces;
using SportsDivision.Application.Mappings;
@@ -10,6 +11,7 @@ public static class DependencyInjection
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddAutoMapper(typeof(MappingProfile).Assembly);
services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly);
services.AddScoped<IStudentService, StudentService>();
services.AddScoped<ISchoolService, SchoolService>();

View File

@@ -1,3 +1,3 @@
using SportsDivision.Application.DTOs;
namespace SportsDivision.Application.Interfaces;
public interface IRegistrationService { Task<IEnumerable<EventRegistrationDto>> GetByTournamentEventLevelAsync(int tournamentEventLevelId); Task<IEnumerable<EventRegistrationDto>> GetByStudentAsync(int studentId); Task<EventRegistrationDto> RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy); Task UnregisterAsync(int eventRegistrationId); Task<(bool IsEligible, string? Reason)> CheckEligibilityAsync(int tournamentEventLevelId, int studentId); }
public interface IRegistrationService { Task<EventRegistrationDto?> GetByIdAsync(int eventRegistrationId); Task<IEnumerable<EventRegistrationDto>> GetByTournamentEventLevelAsync(int tournamentEventLevelId); Task<IEnumerable<EventRegistrationDto>> GetByStudentAsync(int studentId); Task<EventRegistrationDto> RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy); Task UnregisterAsync(int eventRegistrationId); Task<(bool IsEligible, string? Reason)> CheckEligibilityAsync(int tournamentEventLevelId, int studentId); }

View File

@@ -1,3 +1,3 @@
using SportsDivision.Application.DTOs;
namespace SportsDivision.Application.Interfaces;
public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task<ScoreDto> RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId); Task<IEnumerable<ScoringConstantDto>> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task<IEnumerable<PlacementPointConfigDto>> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); }
public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task<ScoreDto> RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId); Task<IEnumerable<ScoringConstantDto>> GetScoringConstantsAsync(); Task CreateScoringConstantAsync(ScoringConstantCreateDto dto); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task DeleteScoringConstantAsync(int scoringConstantId); Task<IEnumerable<PlacementPointConfigDto>> GetPlacementPointConfigsAsync(); Task CreatePlacementPointConfigAsync(PlacementPointConfigCreateDto dto); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); Task DeletePlacementPointConfigAsync(int placementPointConfigId); }

View File

@@ -1,3 +1,3 @@
using SportsDivision.Application.DTOs;
namespace SportsDivision.Application.Interfaces;
public interface IStudentService { Task<IEnumerable<StudentDto>> GetAllAsync(); Task<StudentDto?> GetByIdAsync(int id); Task<StudentDto?> GetByExistingIdAsync(string existingStudentId); Task<IEnumerable<StudentDto>> GetBySchoolAsync(int schoolId); Task<IEnumerable<StudentDto>> SearchAsync(string searchTerm); Task<StudentDto> CreateAsync(StudentCreateDto dto); Task UpdateAsync(StudentUpdateDto dto); Task DeleteAsync(int id); }
public interface IStudentService { Task<IEnumerable<StudentDto>> GetAllAsync(); Task<StudentDto?> GetByIdAsync(int id); Task<StudentDto?> GetByExistingIdAsync(string existingStudentId); Task<IEnumerable<StudentDto>> GetBySchoolAsync(int schoolId); Task<(IReadOnlyList<StudentDto> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize); Task<StudentDto> CreateAsync(StudentCreateDto dto); Task UpdateAsync(StudentUpdateDto dto); Task DeleteAsync(int id); }

View File

@@ -35,18 +35,19 @@ public class DashboardService : IDashboardService
if (tournamentId.HasValue)
{
var tels = (await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId.Value)).ToList();
int regCount = 0;
// One query for the tournament's registrations instead of one per event level.
var regs = (await _uow.EventRegistrations.GetByTournamentAsync(tournamentId.Value)).ToList();
int scoredEvents = 0;
int eventsWithEntries = 0;
var recent = new List<RecentScoreDto>();
foreach (var tel in tels)
foreach (var telGroup in regs.GroupBy(r => r.TournamentEventLevelId))
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
regCount += regs.Count();
eventsWithEntries++;
var tel = telGroup.First().TournamentEventLevel;
bool hasResults = false;
foreach (var reg in regs.Where(r => r.Score != null))
foreach (var reg in telGroup.Where(r => r.Score != null))
{
if (reg.Score!.Placement != null) hasResults = true;
recent.Add(new RecentScoreDto
@@ -62,9 +63,11 @@ public class DashboardService : IDashboardService
if (hasResults) scoredEvents++;
}
dashboard.TotalRegistrations = regCount;
dashboard.TotalRegistrations = regs.Count;
dashboard.EventsCompleted = scoredEvents;
dashboard.EventsInProgress = tels.Count - scoredEvents;
// Only event levels somebody actually entered count as "in progress" —
// empty event levels are neither in progress nor completed.
dashboard.EventsInProgress = eventsWithEntries - scoredEvents;
dashboard.RecentScores = recent.OrderByDescending(r => r.RecordedAt).Take(8).ToList();
var standings = await _scoringService.GetSchoolStandingsAsync(tournamentId.Value);

View File

@@ -71,6 +71,13 @@ public class EventService : IEventService
{
var evt = await _uow.Events.GetByIdAsync(id)
?? throw new KeyNotFoundException("Event not found.");
// Tournament event levels reference events with DeleteBehavior.Restrict —
// check first so the user gets a clear message instead of a database error page.
if (await _uow.TournamentEventLevels.AnyAsync(t => t.EventId == id))
throw new InvalidOperationException(
"This event is used by one or more tournaments. Remove it from those tournaments first, or mark it inactive instead.");
_uow.Events.Remove(evt);
await _uow.SaveChangesAsync();
}

View File

@@ -20,6 +20,10 @@ public class HeatManagementService : IHeatManagementService
public async Task<RoundDto> CreateRoundAsync(RoundCreateDto dto)
{
var existing = await _uow.Rounds.GetByTournamentEventLevelAsync(dto.TournamentEventLevelId);
if (existing.Any(r => r.RoundOrder == dto.RoundOrder))
throw new InvalidOperationException($"A round with order {dto.RoundOrder} already exists for this event.");
var round = _mapper.Map<Round>(dto);
await _uow.Rounds.AddAsync(round);
await _uow.SaveChangesAsync();
@@ -38,8 +42,22 @@ public class HeatManagementService : IHeatManagementService
return round == null ? null : _mapper.Map<RoundDto>(round);
}
/// <summary>
/// Lane numbers in preference order for a heat of <paramref name="laneCount"/> lanes:
/// centre lanes first (4, 5, 3, 6, ... for 8 lanes), as is standard for track seeding,
/// so the highest-seeded athletes in the list get the middle of the track.
/// </summary>
private static int[] PreferredLaneOrder(int laneCount) =>
Enumerable.Range(1, laneCount)
.OrderBy(l => Math.Abs(l - (laneCount + 1) / 2.0))
.ThenBy(l => l)
.ToArray();
public async Task SeedHeatsAsync(int roundId, SeedingMethod method, int lanesPerHeat = 8)
{
if (lanesPerHeat < 1 || lanesPerHeat > 10)
throw new InvalidOperationException("Lanes per heat must be between 1 and 10.");
var round = await _uow.Rounds.GetWithHeatsAsync(roundId)
?? throw new KeyNotFoundException("Round not found.");
@@ -64,7 +82,12 @@ public class HeatManagementService : IHeatManagementService
}
await _uow.SaveChangesAsync();
int heatCount = (int)Math.Ceiling((double)registrations.Count / lanesPerHeat);
await CreateHeatsAsync(roundId, registrations.Select(r => r.EventRegistrationId).ToList(), lanesPerHeat);
}
private async Task CreateHeatsAsync(int roundId, IReadOnlyList<int> registrationIds, int lanesPerHeat)
{
int heatCount = (int)Math.Ceiling((double)registrationIds.Count / lanesPerHeat);
for (int h = 0; h < heatCount; h++)
{
var heat = new Heat
@@ -76,14 +99,15 @@ public class HeatManagementService : IHeatManagementService
await _uow.Heats.AddAsync(heat);
await _uow.SaveChangesAsync();
var heatRegs = registrations.Skip(h * lanesPerHeat).Take(lanesPerHeat).ToList();
var heatRegs = registrationIds.Skip(h * lanesPerHeat).Take(lanesPerHeat).ToList();
var laneOrder = PreferredLaneOrder(lanesPerHeat);
for (int l = 0; l < heatRegs.Count; l++)
{
var lane = new HeatLane
{
HeatId = heat.HeatId,
EventRegistrationId = heatRegs[l].EventRegistrationId,
LaneNumber = l + 1
EventRegistrationId = heatRegs[l],
LaneNumber = laneOrder[l]
};
await _uow.HeatLanes.AddAsync(lane);
}
@@ -93,10 +117,13 @@ public class HeatManagementService : IHeatManagementService
public async Task SaveHeatTimesAsync(int heatId, List<HeatLaneUpdateDto> lanes, string recordedBy)
{
// Only lanes belonging to this heat may be updated; posted IDs from other
// heats (or other events entirely) are ignored.
var heatLanes = (await _uow.HeatLanes.GetByHeatAsync(heatId)).ToDictionary(l => l.HeatLaneId);
foreach (var laneDto in lanes)
{
var lane = await _uow.HeatLanes.GetByIdAsync(laneDto.HeatLaneId);
if (lane == null) continue;
if (!heatLanes.TryGetValue(laneDto.HeatLaneId, out var lane)) continue;
lane.Time = laneDto.Time;
lane.IsDNS = laneDto.IsDNS;
@@ -114,6 +141,19 @@ public class HeatManagementService : IHeatManagementService
var round = await _uow.Rounds.GetWithHeatsAsync(roundId)
?? throw new KeyNotFoundException("Round not found.");
// Recalculation must start from a clean slate: clear previous advancement
// flags so athletes who no longer qualify (e.g. after a time correction)
// do not keep advancing.
foreach (var lane in round.Heats.SelectMany(h => h.HeatLanes))
{
if (lane.IsAdvanced || lane.AdvanceReason != null)
{
lane.IsAdvanced = false;
lane.AdvanceReason = null;
_uow.HeatLanes.Update(lane);
}
}
var allLanes = round.Heats.SelectMany(h => h.HeatLanes)
.Where(l => l.Time.HasValue && !l.IsDNS && !l.IsDNF && !l.IsDQ)
.OrderBy(l => l.Time)
@@ -170,32 +210,23 @@ public class HeatManagementService : IHeatManagementService
var nextRound = nextRounds.FirstOrDefault(r => r.RoundOrder == round.RoundOrder + 1)
?? throw new InvalidOperationException("No next round exists.");
int lanesPerHeat = 8;
int heatCount = (int)Math.Ceiling((double)advancedLanes.Count / lanesPerHeat);
for (int h = 0; h < heatCount; h++)
{
var heat = new Heat
{
RoundId = nextRound.RoundId,
HeatNumber = h + 1,
Status = HeatStatus.Pending
};
await _uow.Heats.AddAsync(heat);
await _uow.SaveChangesAsync();
if (advancedLanes.Count == 0)
throw new InvalidOperationException("No athletes are marked as advancing — run Calculate Advancement first.");
var heatLanes = advancedLanes.Skip(h * lanesPerHeat).Take(lanesPerHeat).ToList();
for (int l = 0; l < heatLanes.Count; l++)
{
var lane = new HeatLane
{
HeatId = heat.HeatId,
EventRegistrationId = heatLanes[l].EventRegistrationId,
LaneNumber = l + 1
};
await _uow.HeatLanes.AddAsync(lane);
}
await _uow.SaveChangesAsync();
// Rebuild the next round from scratch so running this twice (or after a
// correction) replaces the heats instead of duplicating every athlete.
foreach (var heat in nextRound.Heats.ToList())
{
var lanes = await _uow.HeatLanes.GetByHeatAsync(heat.HeatId);
foreach (var lane in lanes)
_uow.HeatLanes.Remove(lane);
_uow.Heats.Remove(heat);
}
await _uow.SaveChangesAsync();
// advancedLanes is ordered fastest-first, so centre-lane preference in
// CreateHeatsAsync gives the fastest qualifiers the middle lanes.
await CreateHeatsAsync(nextRound.RoundId, advancedLanes.Select(l => l.EventRegistrationId).ToList(), lanesPerHeat: 8);
}
public async Task CompleteHeatAsync(int heatId)

View File

@@ -26,7 +26,12 @@ public class HighJumpService : IHighJumpService
public async Task<HighJumpHeightDto> AddHeightAsync(HighJumpHeightCreateDto dto)
{
if (dto.Height <= 0)
throw new InvalidOperationException("Height must be greater than zero.");
var existingHeights = await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(dto.TournamentEventLevelId);
if (existingHeights.Any(h => h.Height == dto.Height))
throw new InvalidOperationException($"A bar height of {dto.Height:0.00}m already exists for this event.");
var maxOrder = existingHeights.Any() ? existingHeights.Max(h => h.SortOrder) : 0;
var height = new HighJumpHeight
@@ -54,6 +59,13 @@ public class HighJumpService : IHighJumpService
var height = await _uow.HighJumpHeights.GetWithAttemptsAsync(dto.HighJumpHeightId)
?? throw new KeyNotFoundException("Height not found.");
// The registration must belong to the same event level as the bar — otherwise
// a crafted POST could write attempts into an unrelated competition.
var registration = await _uow.EventRegistrations.GetByIdAsync(dto.EventRegistrationId)
?? throw new KeyNotFoundException("Registration not found.");
if (registration.TournamentEventLevelId != height.TournamentEventLevelId)
throw new InvalidOperationException("The registration does not belong to this event level.");
var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == dto.EventRegistrationId);
if (attempt == null)
{
@@ -78,21 +90,17 @@ public class HighJumpService : IHighJumpService
public async Task<bool> IsEliminatedAsync(int tournamentEventLevelId, int eventRegistrationId)
{
// Attempts are eager-loaded by the repository; bars are judged in height order.
var heights = await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId);
int consecutiveFails = 0;
foreach (var height in heights.OrderBy(h => h.SortOrder))
foreach (var height in heights.OrderBy(h => h.Height))
{
var fullHeight = await _uow.HighJumpHeights.GetWithAttemptsAsync(height.HighJumpHeightId);
if (fullHeight == null) continue;
var attempt = fullHeight.Attempts.FirstOrDefault(a => a.EventRegistrationId == eventRegistrationId);
var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == eventRegistrationId);
if (attempt == null) continue;
if (attempt.HasCleared)
consecutiveFails = 0;
else if (attempt.IsEliminated)
consecutiveFails += 3;
else
consecutiveFails += attempt.FailCount;
@@ -105,8 +113,10 @@ public class HighJumpService : IHighJumpService
public async Task<string?> CalculateResultsAsync(int tournamentEventLevelId, string recordedBy)
{
// "Highest bar" is the bar with the greatest height, regardless of the order
// the bars were entered; attempts are already eager-loaded by the repository.
var heights = (await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId))
.OrderBy(h => h.SortOrder).ToList();
.OrderBy(h => h.Height).ToList();
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
?? throw new KeyNotFoundException("Tournament event level not found.");
@@ -121,10 +131,7 @@ public class HighJumpService : IHighJumpService
foreach (var height in heights)
{
var fullHeight = await _uow.HighJumpHeights.GetWithAttemptsAsync(height.HighJumpHeightId);
if (fullHeight == null) continue;
var attempt = fullHeight.Attempts.FirstOrDefault(a => a.EventRegistrationId == reg.EventRegistrationId);
var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == reg.EventRegistrationId);
if (attempt == null) continue;
totalFails += attempt.FailCount;
@@ -153,6 +160,14 @@ public class HighJumpService : IHighJumpService
.ToDictionary(p => p.Placement, p => p.Points);
var constant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId);
// Athletes who cleared no bar get no result: remove any leftover Score rows
// (from a run before a correction, or a manual entry) so stale placements
// don't keep feeding the standings and reports.
var rankedRegIds = sorted.Select(r => r.RegId).ToHashSet();
var existingScores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)).ToList();
foreach (var stale in existingScores.Where(s => !rankedRegIds.Contains(s.EventRegistrationId)))
_uow.Scores.Remove(stale);
// Assign placements with proper tie handling. Athletes identical on all three
// countback keys are a genuine tie: they share one placement (standard competition
// ranking, e.g. 1, 2, 3, 3, 5) and share placement points — the points for the

View File

@@ -17,6 +17,12 @@ public class RegistrationService : IRegistrationService
_mapper = mapper;
}
public async Task<EventRegistrationDto?> GetByIdAsync(int eventRegistrationId)
{
var reg = await _uow.EventRegistrations.GetByIdAsync(eventRegistrationId);
return reg == null ? null : _mapper.Map<EventRegistrationDto>(reg);
}
public async Task<IEnumerable<EventRegistrationDto>> GetByTournamentEventLevelAsync(int tournamentEventLevelId)
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tournamentEventLevelId);
@@ -31,7 +37,17 @@ public class RegistrationService : IRegistrationService
public async Task<EventRegistrationDto> RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy)
{
var (isEligible, reason) = await CheckEligibilityAsync(dto.TournamentEventLevelId, dto.StudentId!.Value);
if (!dto.StudentId.HasValue)
{
// Relay teams are modelled in the domain but have no management UI or
// eligibility rules yet, so relay-only registrations are rejected rather
// than crashing on the missing student.
throw new InvalidOperationException(dto.RelayTeamId.HasValue
? "Relay team registration is not supported yet — register individual athletes."
: "Select a student to register.");
}
var (isEligible, reason) = await CheckEligibilityAsync(dto.TournamentEventLevelId, dto.StudentId.Value);
if (!isEligible)
throw new InvalidOperationException($"Student is not eligible: {reason}");
@@ -53,6 +69,17 @@ public class RegistrationService : IRegistrationService
{
var reg = await _uow.EventRegistrations.GetByIdAsync(eventRegistrationId)
?? throw new KeyNotFoundException("Registration not found.");
// Heat lanes and high jump attempts reference registrations with
// DeleteBehavior.Restrict — check first so the user gets a clear message
// instead of a database error page.
if (await _uow.HeatLanes.AnyAsync(l => l.EventRegistrationId == eventRegistrationId))
throw new InvalidOperationException(
"This registration is assigned to one or more heats. Re-seed the affected round without this athlete before unregistering.");
if (await _uow.HighJumpHeights.HasAttemptsForRegistrationAsync(eventRegistrationId))
throw new InvalidOperationException(
"This registration has recorded high jump attempts and cannot be removed.");
_uow.EventRegistrations.Remove(reg);
await _uow.SaveChangesAsync();
}
@@ -65,6 +92,15 @@ public class RegistrationService : IRegistrationService
var student = await _uow.Students.GetByIdAsync(studentId);
if (student == null) return (false, "Student not found.");
// Registrations must not alter finished or archived tournaments.
if (tel.Tournament.IsArchived)
return (false, "This tournament is archived and can no longer accept registrations.");
if (tel.Tournament.Status == Domain.Enums.TournamentStatus.Completed)
return (false, "This tournament is completed and can no longer accept registrations.");
if (!student.IsActive)
return (false, "Student is deactivated.");
// Check sex match
if (student.Sex != tel.EventLevel.Sex)
return (false, $"Student sex ({student.Sex}) does not match event level ({tel.EventLevel.Sex}).");
@@ -72,6 +108,8 @@ public class RegistrationService : IRegistrationService
// Check school level compatibility
var school = await _uow.Schools.GetByIdAsync(student.SchoolId);
if (school == null) return (false, "Student's school not found.");
if (!school.IsActive)
return (false, "Student's school is deactivated.");
bool schoolLevelMatch = tel.EventLevel.SchoolLevel == school.SchoolLevel;
// Allow "compete up" — primary students can enter secondary events, but not the reverse

View File

@@ -13,27 +13,27 @@ public class ReportService : IReportService
_uow = uow;
}
// Every report loads the tournament's registrations (with student, school, zone,
// event, level and score) in a single query and aggregates in memory, instead of
// issuing one query per event level.
public async Task<IEnumerable<PopularEventsReportDto>> GetPopularEventsAsync(int tournamentId)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
var report = new Dictionary<string, PopularEventsReportDto>();
foreach (var tel in tels)
foreach (var reg in regs)
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
var eventName = tel.Event?.Name ?? "Unknown";
var category = tel.Event?.Category.ToString() ?? "Unknown";
var eventName = reg.TournamentEventLevel.Event?.Name ?? "Unknown";
var category = reg.TournamentEventLevel.Event?.Category.ToString() ?? "Unknown";
if (!report.ContainsKey(eventName))
report[eventName] = new PopularEventsReportDto { EventName = eventName, Category = category };
var entry = report[eventName];
foreach (var reg in regs)
{
entry.RegistrationCount++;
if (reg.Student?.Sex == Domain.Enums.Sex.Male) entry.MaleCount++;
else if (reg.Student?.Sex == Domain.Enums.Sex.Female) entry.FemaleCount++;
}
entry.RegistrationCount++;
if (reg.Student?.Sex == Domain.Enums.Sex.Male) entry.MaleCount++;
else if (reg.Student?.Sex == Domain.Enums.Sex.Female) entry.FemaleCount++;
}
return report.Values.OrderByDescending(r => r.RegistrationCount);
@@ -41,15 +41,15 @@ public class ReportService : IReportService
public async Task<IEnumerable<RegistrationByGenderReportDto>> GetRegistrationByGenderAsync(int tournamentId, int? zoneId = null)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
var report = new List<RegistrationByGenderReportDto>();
foreach (var tel in tels)
foreach (var group in regs.GroupBy(r => r.TournamentEventLevelId))
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
var tel = group.First().TournamentEventLevel;
var filteredRegs = zoneId.HasValue
? regs.Where(r => r.Student?.School?.ZoneId == zoneId.Value)
: regs;
? group.Where(r => r.Student?.School?.ZoneId == zoneId.Value)
: group.AsEnumerable();
var dto = new RegistrationByGenderReportDto
{
@@ -67,16 +67,19 @@ public class ReportService : IReportService
public async Task<IEnumerable<EventSchoolReportDto>> GetEventSchoolReportAsync(int tournamentId, int? eventId = null, int? eventLevelId = null)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
var filtered = tels.AsEnumerable();
if (eventId.HasValue) filtered = filtered.Where(t => t.EventId == eventId.Value);
if (eventLevelId.HasValue) filtered = filtered.Where(t => t.EventLevelId == eventLevelId.Value);
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
var filtered = regs.AsEnumerable();
if (eventId.HasValue) filtered = filtered.Where(r => r.TournamentEventLevel.EventId == eventId.Value);
if (eventLevelId.HasValue) filtered = filtered.Where(r => r.TournamentEventLevel.EventLevelId == eventLevelId.Value);
var report = new List<EventSchoolReportDto>();
foreach (var tel in filtered)
foreach (var telGroup in filtered
.GroupBy(r => r.TournamentEventLevelId)
.OrderBy(g => g.First().TournamentEventLevel.EventLevel?.SortOrder ?? 0)
.ThenBy(g => g.First().TournamentEventLevel.Event?.Name))
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
var schoolGroups = regs
var tel = telGroup.First().TournamentEventLevel;
var schoolGroups = telGroup
.Where(r => r.Student?.School != null)
.GroupBy(r => r.Student!.School!.Name);
@@ -108,42 +111,38 @@ public class ReportService : IReportService
public async Task<IEnumerable<StudentsBySchoolReportDto>> GetStudentsBySchoolAsync(int tournamentId, int? schoolId = null, int? zoneId = null)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
var schoolStudents = new Dictionary<string, StudentsBySchoolReportDto>();
foreach (var tel in tels)
foreach (var reg in regs)
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
foreach (var reg in regs)
if (reg.Student?.School == null) continue;
if (schoolId.HasValue && reg.Student.SchoolId != schoolId.Value) continue;
if (zoneId.HasValue && reg.Student.School.ZoneId != zoneId.Value) continue;
var schoolName = reg.Student.School.Name;
if (!schoolStudents.ContainsKey(schoolName))
{
if (reg.Student?.School == null) continue;
if (schoolId.HasValue && reg.Student.SchoolId != schoolId.Value) continue;
if (zoneId.HasValue && reg.Student.School.ZoneId != zoneId.Value) continue;
var schoolName = reg.Student.School.Name;
if (!schoolStudents.ContainsKey(schoolName))
schoolStudents[schoolName] = new StudentsBySchoolReportDto
{
schoolStudents[schoolName] = new StudentsBySchoolReportDto
{
SchoolName = schoolName,
ZoneName = reg.Student.School.Zone?.Name ?? "Unknown"
};
}
var existingStudent = schoolStudents[schoolName].Students
.FirstOrDefault(s => s.StudentName == reg.Student.FullName);
if (existingStudent == null)
{
existingStudent = new StudentEventEntryDto
{
StudentName = reg.Student.FullName,
Sex = reg.Student.Sex.ToString()
};
schoolStudents[schoolName].Students.Add(existingStudent);
}
var eventDesc = $"{tel.Event?.Name} ({tel.EventLevel?.Name})";
existingStudent.Events.Add(eventDesc);
SchoolName = schoolName,
ZoneName = reg.Student.School.Zone?.Name ?? "Unknown"
};
}
var existingStudent = schoolStudents[schoolName].Students
.FirstOrDefault(s => s.StudentName == reg.Student.FullName);
if (existingStudent == null)
{
existingStudent = new StudentEventEntryDto
{
StudentName = reg.Student.FullName,
Sex = reg.Student.Sex.ToString()
};
schoolStudents[schoolName].Students.Add(existingStudent);
}
var eventDesc = $"{reg.TournamentEventLevel.Event?.Name} ({reg.TournamentEventLevel.EventLevel?.Name})";
existingStudent.Events.Add(eventDesc);
}
return schoolStudents.Values.OrderBy(s => s.SchoolName);
@@ -151,30 +150,31 @@ public class ReportService : IReportService
public async Task<IEnumerable<ScoresByEventReportDto>> GetScoresByEventAsync(int tournamentId, int? eventId = null, int? eventLevelId = null)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
var filtered = tels.AsEnumerable();
if (eventId.HasValue) filtered = filtered.Where(t => t.EventId == eventId.Value);
if (eventLevelId.HasValue) filtered = filtered.Where(t => t.EventLevelId == eventLevelId.Value);
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
var filtered = regs.Where(r => r.Score != null);
if (eventId.HasValue) filtered = filtered.Where(r => r.TournamentEventLevel.EventId == eventId.Value);
if (eventLevelId.HasValue) filtered = filtered.Where(r => r.TournamentEventLevel.EventLevelId == eventLevelId.Value);
var report = new List<ScoresByEventReportDto>();
foreach (var tel in filtered)
foreach (var telGroup in filtered
.GroupBy(r => r.TournamentEventLevelId)
.OrderBy(g => g.First().TournamentEventLevel.EventLevel?.SortOrder ?? 0)
.ThenBy(g => g.First().TournamentEventLevel.Event?.Name))
{
var scores = await _uow.Scores.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
if (!scores.Any()) continue;
var tel = telGroup.First().TournamentEventLevel;
var dto = new ScoresByEventReportDto
{
EventName = tel.Event?.Name ?? "Unknown",
EventLevelName = tel.EventLevel?.Name ?? "Unknown",
Category = tel.Event?.Category.ToString() ?? "Unknown",
Scores = scores.OrderBy(s => s.Placement ?? int.MaxValue).Select(s => new ScoreEntryDto
Scores = telGroup.OrderBy(r => r.Score!.Placement ?? int.MaxValue).Select(r => new ScoreEntryDto
{
Placement = s.Placement,
StudentName = s.EventRegistration?.Student?.FullName ?? "Unknown",
SchoolName = s.EventRegistration?.Student?.School?.Name ?? "Unknown",
RawPerformance = s.RawPerformance,
CalculatedPoints = s.CalculatedPoints,
PlacementPoints = s.PlacementPoints
Placement = r.Score!.Placement,
StudentName = r.Student?.FullName ?? "Unknown",
SchoolName = r.Student?.School?.Name ?? "Unknown",
RawPerformance = r.Score.RawPerformance,
CalculatedPoints = r.Score.CalculatedPoints,
PlacementPoints = r.Score.PlacementPoints
}).ToList()
};
report.Add(dto);
@@ -184,40 +184,36 @@ public class ReportService : IReportService
public async Task<IEnumerable<StudentPointsReportDto>> GetStudentPointsAsync(int tournamentId, int? schoolId = null, int? zoneId = null)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
var studentPoints = new Dictionary<int, StudentPointsReportDto>();
foreach (var tel in tels)
foreach (var reg in regs)
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
foreach (var reg in regs)
if (reg.Student == null || reg.Score == null) continue;
if (schoolId.HasValue && reg.Student.SchoolId != schoolId.Value) continue;
if (zoneId.HasValue && reg.Student.School?.ZoneId != zoneId.Value) continue;
var studentId = reg.Student.StudentId;
if (!studentPoints.ContainsKey(studentId))
{
if (reg.Student == null || reg.Score == null) continue;
if (schoolId.HasValue && reg.Student.SchoolId != schoolId.Value) continue;
if (zoneId.HasValue && reg.Student.School?.ZoneId != zoneId.Value) continue;
var studentId = reg.Student.StudentId;
if (!studentPoints.ContainsKey(studentId))
studentPoints[studentId] = new StudentPointsReportDto
{
studentPoints[studentId] = new StudentPointsReportDto
{
StudentName = reg.Student.FullName,
SchoolName = reg.Student.School?.Name ?? "Unknown",
Sex = reg.Student.Sex.ToString()
};
}
var entry = studentPoints[studentId];
entry.TotalPlacementPoints += reg.Score.PlacementPoints;
entry.EventCount++;
entry.EventScores.Add(new StudentEventScoreDto
{
EventName = tel.Event?.Name ?? "Unknown",
EventLevelName = tel.EventLevel?.Name ?? "Unknown",
Placement = reg.Score.Placement,
PlacementPoints = reg.Score.PlacementPoints
});
StudentName = reg.Student.FullName,
SchoolName = reg.Student.School?.Name ?? "Unknown",
Sex = reg.Student.Sex.ToString()
};
}
var entry = studentPoints[studentId];
entry.TotalPlacementPoints += reg.Score.PlacementPoints;
entry.EventCount++;
entry.EventScores.Add(new StudentEventScoreDto
{
EventName = reg.TournamentEventLevel.Event?.Name ?? "Unknown",
EventLevelName = reg.TournamentEventLevel.EventLevel?.Name ?? "Unknown",
Placement = reg.Score.Placement,
PlacementPoints = reg.Score.PlacementPoints
});
}
return studentPoints.Values.OrderByDescending(s => s.TotalPlacementPoints);

View File

@@ -63,6 +63,13 @@ public class SchoolService : ISchoolService
{
var school = await _uow.Schools.GetByIdAsync(id)
?? throw new KeyNotFoundException("School not found.");
// Students reference schools with DeleteBehavior.Restrict — check first so
// the user gets a clear message instead of a database error page.
if (await _uow.Students.AnyAsync(s => s.SchoolId == id))
throw new InvalidOperationException(
"This school still has students. Move or delete its students first, or deactivate the school instead.");
_uow.Schools.Remove(school);
await _uow.SaveChangesAsync();
}

View File

@@ -37,27 +37,34 @@ public class ScoringService : IScoringService
var reg = await _uow.EventRegistrations.GetByIdAsync(dto.EventRegistrationId)
?? throw new KeyNotFoundException("Registration not found.");
var existingScore = await _uow.Scores.GetByRegistrationAsync(dto.EventRegistrationId);
if (existingScore != null)
{
existingScore.RawPerformance = dto.RawPerformance;
existingScore.RecordedBy = recordedBy;
existingScore.RecordedAt = DateTime.UtcNow;
_uow.Scores.Update(existingScore);
await _uow.SaveChangesAsync();
return _mapper.Map<ScoreDto>(existingScore);
}
// Keep the WA points in step with the mark: a corrected performance must not
// leave the previously calculated points behind.
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(reg.TournamentEventLevelId);
var constant = tel != null ? await _uow.ScoringConstants.GetByEventAsync(tel.EventId) : null;
bool isTrack = tel?.Event.Category == Domain.Enums.EventCategory.Track;
int calculated = constant != null
? CalculatePoints(dto.RawPerformance, constant.A, constant.B, constant.C, isTrack)
: 0;
var score = new Score
{
EventRegistrationId = dto.EventRegistrationId,
RawPerformance = dto.RawPerformance,
RecordedBy = recordedBy,
RecordedAt = DateTime.UtcNow
};
var score = await _uow.Scores.GetByRegistrationAsync(dto.EventRegistrationId);
var isNew = score == null;
score ??= new Score { EventRegistrationId = dto.EventRegistrationId };
await _uow.Scores.AddAsync(score);
score.RawPerformance = dto.RawPerformance;
score.CalculatedPoints = calculated;
score.RecordedBy = recordedBy;
score.RecordedAt = DateTime.UtcNow;
if (isNew) await _uow.Scores.AddAsync(score);
else _uow.Scores.Update(score);
await _uow.SaveChangesAsync();
// If placements were already calculated for this event, refresh them so a
// corrected mark doesn't leave the standings stale.
var siblingScores = await _uow.Scores.GetByTournamentEventLevelAsync(reg.TournamentEventLevelId);
if (siblingScores.Any(s => s.Placement != null))
await CalculatePlacementsAsync(reg.TournamentEventLevelId);
return _mapper.Map<ScoreDto>(score);
}
@@ -66,15 +73,16 @@ public class ScoringService : IScoringService
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
?? throw new KeyNotFoundException("Tournament event level not found.");
var scoringConstant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId);
if (scoringConstant == null) return;
var scoringConstant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId)
?? throw new InvalidOperationException(
$"No scoring constant is configured for {tel.Event.Name}. Add one in Scoring Configuration before calculating points.");
bool isTrack = tel.Event.Category == Domain.Enums.EventCategory.Track;
foreach (var reg in tel.Registrations)
{
var score = await _uow.Scores.GetByRegistrationAsync(reg.EventRegistrationId);
if (score == null || score.RawPerformance == 0) continue;
if (score == null) continue;
score.CalculatedPoints = CalculatePoints(
score.RawPerformance,
@@ -147,52 +155,88 @@ public class ScoringService : IScoringService
public async Task CalculatePlacementsAsync(int tournamentEventLevelId)
{
var scores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId))
.Where(s => s.RawPerformance > 0)
.OrderByDescending(s => s.CalculatedPoints)
.ToList();
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
?? throw new KeyNotFoundException("Tournament event level not found.");
// Rank on the recorded performance itself (not on WA points, which are all
// zero when no scoring constant exists). Track ranks ascending (faster is
// better); field ranks descending (further is better).
bool lowerIsBetter = tel.Event.Category == Domain.Enums.EventCategory.Track;
var allScores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)).ToList();
var ranked = allScores.Where(s => s.RawPerformance > 0).ToList();
ranked = lowerIsBetter
? ranked.OrderBy(s => s.RawPerformance).ToList()
: ranked.OrderByDescending(s => s.RawPerformance).ToList();
// Scores without a valid mark (e.g. a foul recorded as 0) get no placement;
// clear anything left over from an earlier calculation.
foreach (var unranked in allScores.Where(s => s.RawPerformance <= 0))
{
if (unranked.Placement != null || unranked.PlacementPoints != 0)
{
unranked.Placement = null;
unranked.PlacementPoints = 0;
_uow.Scores.Update(unranked);
}
}
var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync())
.ToDictionary(p => p.Placement, p => p.Points);
for (int i = 0; i < scores.Count; i++)
// Identical performances share a placement (standard competition ranking:
// 1, 2, 2, 4) and split the pooled placement points for the positions the
// tie-group occupies — the same convention as the high jump path.
int i = 0;
while (i < ranked.Count)
{
scores[i].Placement = i + 1;
scores[i].PlacementPoints = placementPoints.TryGetValue(i + 1, out var pts) ? pts : 0;
_uow.Scores.Update(scores[i]);
int start = i;
int place = start + 1;
while (i + 1 < ranked.Count && ranked[i + 1].RawPerformance == ranked[start].RawPerformance) i++;
int size = i - start + 1;
int pooled = 0;
for (int p = place; p < place + size; p++)
pooled += placementPoints.TryGetValue(p, out var pp) ? pp : 0;
int shared = (int)Math.Round((double)pooled / size, MidpointRounding.AwayFromZero);
for (int k = start; k <= i; k++)
{
ranked[k].Placement = place;
ranked[k].PlacementPoints = shared;
_uow.Scores.Update(ranked[k]);
}
i++;
}
await _uow.SaveChangesAsync();
}
public async Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId);
// One query for the whole tournament — this runs on every dashboard load.
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
var schoolPoints = new Dictionary<int, SchoolPointsSummaryDto>();
foreach (var tel in tels)
foreach (var reg in regs)
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
foreach (var reg in regs)
if (reg.Student == null || reg.Score == null) continue;
var schoolId = reg.Student.SchoolId;
if (!schoolPoints.ContainsKey(schoolId))
{
if (reg.Student == null || reg.Score == null) continue;
var schoolId = reg.Student.SchoolId;
if (!schoolPoints.ContainsKey(schoolId))
schoolPoints[schoolId] = new SchoolPointsSummaryDto
{
schoolPoints[schoolId] = new SchoolPointsSummaryDto
{
SchoolId = schoolId,
SchoolName = reg.Student.School?.Name ?? string.Empty,
ShortName = reg.Student.School?.ShortName
};
}
var summary = schoolPoints[schoolId];
summary.TotalPoints += reg.Score.PlacementPoints;
if (reg.Score.Placement == 1) summary.FirstPlaceCount++;
else if (reg.Score.Placement == 2) summary.SecondPlaceCount++;
else if (reg.Score.Placement == 3) summary.ThirdPlaceCount++;
SchoolId = schoolId,
SchoolName = reg.Student.School?.Name ?? string.Empty,
ShortName = reg.Student.School?.ShortName
};
}
var summary = schoolPoints[schoolId];
summary.TotalPoints += reg.Score.PlacementPoints;
if (reg.Score.Placement == 1) summary.FirstPlaceCount++;
else if (reg.Score.Placement == 2) summary.SecondPlaceCount++;
else if (reg.Score.Placement == 3) summary.ThirdPlaceCount++;
}
return schoolPoints.Values
@@ -208,6 +252,32 @@ public class ScoringService : IScoringService
return _mapper.Map<IEnumerable<ScoringConstantDto>>(constants);
}
public async Task CreateScoringConstantAsync(ScoringConstantCreateDto dto)
{
var evt = await _uow.Events.GetByIdAsync(dto.EventId)
?? throw new KeyNotFoundException("Event not found.");
if (await _uow.ScoringConstants.GetByEventAsync(dto.EventId) != null)
throw new InvalidOperationException($"{evt.Name} already has a scoring constant — edit the existing one instead.");
await _uow.ScoringConstants.AddAsync(new ScoringConstant
{
EventId = dto.EventId,
A = dto.A,
B = dto.B,
C = dto.C,
Unit = dto.Unit
});
await _uow.SaveChangesAsync();
}
public async Task DeleteScoringConstantAsync(int scoringConstantId)
{
var constant = await _uow.ScoringConstants.GetByIdAsync(scoringConstantId)
?? throw new KeyNotFoundException("Scoring constant not found.");
_uow.ScoringConstants.Remove(constant);
await _uow.SaveChangesAsync();
}
public async Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto)
{
var constant = await _uow.ScoringConstants.GetByIdAsync(dto.ScoringConstantId)
@@ -225,6 +295,29 @@ public class ScoringService : IScoringService
return _mapper.Map<IEnumerable<PlacementPointConfigDto>>(configs);
}
public async Task CreatePlacementPointConfigAsync(PlacementPointConfigCreateDto dto)
{
if (dto.Placement < 1)
throw new InvalidOperationException("Placement must be 1 or higher.");
if (await _uow.PlacementPointConfigs.AnyAsync(p => p.Placement == dto.Placement))
throw new InvalidOperationException($"Placement {dto.Placement} already has points configured — edit the existing entry instead.");
await _uow.PlacementPointConfigs.AddAsync(new PlacementPointConfig
{
Placement = dto.Placement,
Points = dto.Points
});
await _uow.SaveChangesAsync();
}
public async Task DeletePlacementPointConfigAsync(int placementPointConfigId)
{
var config = await _uow.PlacementPointConfigs.GetByIdAsync(placementPointConfigId)
?? throw new KeyNotFoundException("Placement point config not found.");
_uow.PlacementPointConfigs.Remove(config);
await _uow.SaveChangesAsync();
}
public async Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto)
{
var config = await _uow.PlacementPointConfigs.GetByIdAsync(dto.PlacementPointConfigId)

View File

@@ -41,14 +41,15 @@ public class StudentService : IStudentService
return _mapper.Map<IEnumerable<StudentDto>>(students);
}
public async Task<IEnumerable<StudentDto>> SearchAsync(string searchTerm)
public async Task<(IReadOnlyList<StudentDto> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize)
{
var students = await _uow.Students.SearchAsync(searchTerm);
return _mapper.Map<IEnumerable<StudentDto>>(students);
var (students, total) = await _uow.Students.GetPagedAsync(schoolId, searchTerm, page, pageSize);
return (_mapper.Map<IReadOnlyList<StudentDto>>(students), total);
}
public async Task<StudentDto> CreateAsync(StudentCreateDto dto)
{
await NormalizeAndCheckExistingIdAsync(dto, studentId: null);
var student = _mapper.Map<Student>(dto);
await _uow.Students.AddAsync(student);
await _uow.SaveChangesAsync();
@@ -59,15 +60,39 @@ public class StudentService : IStudentService
{
var student = await _uow.Students.GetByIdAsync(dto.StudentId)
?? throw new KeyNotFoundException("Student not found.");
await NormalizeAndCheckExistingIdAsync(dto, dto.StudentId);
_mapper.Map(dto, student);
_uow.Students.Update(student);
await _uow.SaveChangesAsync();
}
// The external student ID is optional and unique-when-present (filtered index).
// Normalise blank to null and pre-check duplicates so the user sees a message
// instead of a database error page.
private async Task NormalizeAndCheckExistingIdAsync(StudentCreateDto dto, int? studentId)
{
dto.ExistingStudentId = string.IsNullOrWhiteSpace(dto.ExistingStudentId)
? null
: dto.ExistingStudentId.Trim();
if (dto.ExistingStudentId != null &&
await _uow.Students.AnyAsync(s => s.ExistingStudentId == dto.ExistingStudentId && s.StudentId != studentId))
{
throw new InvalidOperationException($"A student with ID \"{dto.ExistingStudentId}\" already exists.");
}
}
public async Task DeleteAsync(int id)
{
var student = await _uow.Students.GetByIdAsync(id)
?? throw new KeyNotFoundException("Student not found.");
// Registrations reference students with DeleteBehavior.Restrict — check first
// so the user gets a clear message instead of a database error page.
if (await _uow.EventRegistrations.AnyAsync(r => r.StudentId == id))
throw new InvalidOperationException(
"This student has event registrations. Remove the registrations first, or deactivate the student instead.");
_uow.Students.Remove(student);
await _uow.SaveChangesAsync();
}

View File

@@ -106,19 +106,8 @@ public class TournamentService : ITournamentService
public async Task<IEnumerable<TournamentEventLevelDto>> GetEventLevelsByCategoryAsync(EventCategory category)
{
var tournaments = await _uow.Tournaments.GetActiveAsync();
var result = new List<TournamentEventLevelDto>();
foreach (var t in tournaments)
{
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(t.TournamentId);
foreach (var tel in tels.Where(x => x.Event != null && x.Event.Category == category))
{
var dto = _mapper.Map<TournamentEventLevelDto>(tel);
dto.TournamentName = t.Name;
result.Add(dto);
}
}
return result;
var tels = await _uow.TournamentEventLevels.GetByCategoryAsync(category);
return _mapper.Map<IEnumerable<TournamentEventLevelDto>>(tels);
}
public async Task<TournamentEventLevelDto> AddEventLevelAsync(TournamentEventLevelCreateDto dto)

View File

@@ -10,8 +10,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="13.*" />
<!-- 14.0.x fixes GHSA-rvv3-g6hj-g44x; it is the last major version under the
original licence (the commercial licence change lands in 15.x). -->
<PackageReference Include="AutoMapper" Version="14.0.*" />
<PackageReference Include="FluentValidation" Version="11.*" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.*" />
</ItemGroup>
</Project>

View File

@@ -5,7 +5,8 @@ namespace SportsDivision.Domain.Entities;
public class Student
{
public int StudentId { get; set; }
public string ExistingStudentId { get; set; } = string.Empty;
// Optional external identifier (e.g. a ministry student number); unique when present.
public string? ExistingStudentId { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public DateOnly DateOfBirth { get; set; }

View File

@@ -1,8 +1,9 @@
namespace SportsDivision.Domain.Enums;
// ByPerformance and Manual seeding were declared here but never implemented —
// selecting them silently produced unseeded heats. They can be re-added when a
// seed-mark source and a manual lane editor actually exist.
public enum SeedingMethod
{
Random,
ByPerformance,
Manual
Random
}

View File

@@ -5,6 +5,10 @@ namespace SportsDivision.Domain.Interfaces;
public interface IEventRegistrationRepository : IRepository<EventRegistration>
{
Task<IEnumerable<EventRegistration>> GetByTournamentEventLevelAsync(int tournamentEventLevelId);
/// <summary>All registrations for a tournament in one query (with student, school,
/// zone, event, level and score) — for reports and standings, instead of one
/// query per event level.</summary>
Task<IEnumerable<EventRegistration>> GetByTournamentAsync(int tournamentId);
Task<IEnumerable<EventRegistration>> GetByStudentAsync(int studentId);
Task<bool> IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId);
Task<IEnumerable<EventRegistration>> GetBySchoolAndTournamentAsync(int schoolId, int tournamentId);

View File

@@ -4,6 +4,7 @@ namespace SportsDivision.Domain.Interfaces;
public interface IHighJumpHeightRepository : IRepository<HighJumpHeight>
{
Task<bool> HasAttemptsForRegistrationAsync(int eventRegistrationId);
Task<IEnumerable<HighJumpHeight>> GetByTournamentEventLevelAsync(int tournamentEventLevelId);
Task<HighJumpHeight?> GetWithAttemptsAsync(int heightId);
}

View File

@@ -7,5 +7,9 @@ public interface IStudentRepository : IRepository<Student>
Task<Student?> GetByExistingIdAsync(string existingStudentId);
Task<IEnumerable<Student>> GetBySchoolAsync(int schoolId);
Task<Student?> GetWithRegistrationsAsync(int studentId);
Task<IEnumerable<Student>> SearchAsync(string searchTerm);
/// <summary>
/// Server-side filtered, ordered and paged student query. A <paramref name="pageSize"/>
/// of 0 (or less) returns all matching rows. Returns the page plus the total match count.
/// </summary>
Task<(IReadOnlyList<Student> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize);
}

View File

@@ -1,9 +1,12 @@
using SportsDivision.Domain.Entities;
using SportsDivision.Domain.Enums;
namespace SportsDivision.Domain.Interfaces;
public interface ITournamentEventLevelRepository : IRepository<TournamentEventLevel>
{
/// <summary>Event levels of the given category across all non-archived tournaments, in one query.</summary>
Task<IEnumerable<TournamentEventLevel>> GetByCategoryAsync(EventCategory category);
Task<TournamentEventLevel?> GetWithRegistrationsAsync(int id);
Task<TournamentEventLevel?> GetWithRoundsAsync(int id);
Task<IEnumerable<TournamentEventLevel>> GetByTournamentAsync(int tournamentId);

View File

@@ -11,5 +11,6 @@ public class HeatConfiguration : IEntityTypeConfiguration<Heat>
builder.HasKey(h => h.HeatId);
builder.Property(h => h.Status).HasConversion<string>().HasMaxLength(20);
builder.HasOne(h => h.Round).WithMany(r => r.Heats).HasForeignKey(h => h.RoundId).OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(h => new { h.RoundId, h.HeatNumber }).IsUnique();
}
}

View File

@@ -11,5 +11,8 @@ public class HighJumpHeightConfiguration : IEntityTypeConfiguration<HighJumpHeig
builder.HasKey(h => h.HighJumpHeightId);
builder.Property(h => h.Height).HasPrecision(5, 2);
builder.HasOne(h => h.TournamentEventLevel).WithMany(t => t.HighJumpHeights).HasForeignKey(h => h.TournamentEventLevelId).OnDelete(DeleteBehavior.Cascade);
// The same bar can't be added twice; ordering derives from Height, so this
// also keeps the countback's "highest bar" deterministic.
builder.HasIndex(h => new { h.TournamentEventLevelId, h.Height }).IsUnique();
}
}

View File

@@ -12,5 +12,8 @@ public class RoundConfiguration : IEntityTypeConfiguration<Round>
builder.Property(r => r.RoundType).HasConversion<string>().HasMaxLength(20);
builder.Property(r => r.Status).HasConversion<string>().HasMaxLength(20);
builder.HasOne(r => r.TournamentEventLevel).WithMany(t => t.Rounds).HasForeignKey(r => r.TournamentEventLevelId).OnDelete(DeleteBehavior.Cascade);
// Round order drives next-round lookup and final-round fallback; duplicates
// would make both non-deterministic.
builder.HasIndex(r => new { r.TournamentEventLevelId, r.RoundOrder }).IsUnique();
}
}

View File

@@ -9,8 +9,10 @@ public class StudentConfiguration : IEntityTypeConfiguration<Student>
public void Configure(EntityTypeBuilder<Student> builder)
{
builder.HasKey(s => s.StudentId);
builder.Property(s => s.ExistingStudentId).IsRequired().HasMaxLength(50);
builder.HasIndex(s => s.ExistingStudentId).IsUnique();
builder.Property(s => s.ExistingStudentId).HasMaxLength(50);
// Optional field: uniqueness only applies when a value is present, so multiple
// students without an external ID don't collide on the index.
builder.HasIndex(s => s.ExistingStudentId).IsUnique().HasFilter("\"ExistingStudentId\" IS NOT NULL");
builder.Property(s => s.FirstName).IsRequired().HasMaxLength(100);
builder.Property(s => s.LastName).IsRequired().HasMaxLength(100);
builder.Property(s => s.Sex).HasConversion<string>().HasMaxLength(10);

View File

@@ -27,6 +27,7 @@ public static class DependencyInjection
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddClaimsPrincipalFactory<AppClaimsPrincipalFactory>()
.AddDefaultTokenProviders();
services.ConfigureApplicationCookie(options =>

View File

@@ -0,0 +1,28 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace SportsDivision.Infrastructure.Identity;
/// <summary>
/// Adds the user's first/last name to the sign-in cookie so the layout can render
/// the avatar and display name from claims, without a database query per request.
/// </summary>
public class AppClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
{
public AppClaimsPrincipalFactory(
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IOptions<IdentityOptions> options)
: base(userManager, roleManager, options) { }
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user)
{
var identity = await base.GenerateClaimsAsync(user);
if (!string.IsNullOrEmpty(user.FirstName))
identity.AddClaim(new Claim(ClaimTypes.GivenName, user.FirstName));
if (!string.IsNullOrEmpty(user.LastName))
identity.AddClaim(new Claim(ClaimTypes.Surname, user.LastName));
return identity;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,122 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SportsDivision.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SchemaIntegrityFixes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Students_ExistingStudentId",
table: "Students");
migrationBuilder.DropIndex(
name: "IX_Rounds_TournamentEventLevelId",
table: "Rounds");
migrationBuilder.DropIndex(
name: "IX_HighJumpHeights_TournamentEventLevelId",
table: "HighJumpHeights");
migrationBuilder.DropIndex(
name: "IX_Heats_RoundId",
table: "Heats");
migrationBuilder.AlterColumn<string>(
name: "ExistingStudentId",
table: "Students",
type: "character varying(50)",
maxLength: 50,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50);
// The field used to be required, so students without an external ID were
// stored as "" — normalise to NULL so the filtered unique index applies.
migrationBuilder.Sql("UPDATE \"Students\" SET \"ExistingStudentId\" = NULL WHERE \"ExistingStudentId\" = '';");
migrationBuilder.CreateIndex(
name: "IX_Students_ExistingStudentId",
table: "Students",
column: "ExistingStudentId",
unique: true,
filter: "\"ExistingStudentId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Rounds_TournamentEventLevelId_RoundOrder",
table: "Rounds",
columns: new[] { "TournamentEventLevelId", "RoundOrder" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_HighJumpHeights_TournamentEventLevelId_Height",
table: "HighJumpHeights",
columns: new[] { "TournamentEventLevelId", "Height" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Heats_RoundId_HeatNumber",
table: "Heats",
columns: new[] { "RoundId", "HeatNumber" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Students_ExistingStudentId",
table: "Students");
migrationBuilder.DropIndex(
name: "IX_Rounds_TournamentEventLevelId_RoundOrder",
table: "Rounds");
migrationBuilder.DropIndex(
name: "IX_HighJumpHeights_TournamentEventLevelId_Height",
table: "HighJumpHeights");
migrationBuilder.DropIndex(
name: "IX_Heats_RoundId_HeatNumber",
table: "Heats");
migrationBuilder.AlterColumn<string>(
name: "ExistingStudentId",
table: "Students",
type: "character varying(50)",
maxLength: 50,
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "character varying(50)",
oldMaxLength: 50,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Students_ExistingStudentId",
table: "Students",
column: "ExistingStudentId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Rounds_TournamentEventLevelId",
table: "Rounds",
column: "TournamentEventLevelId");
migrationBuilder.CreateIndex(
name: "IX_HighJumpHeights_TournamentEventLevelId",
table: "HighJumpHeights",
column: "TournamentEventLevelId");
migrationBuilder.CreateIndex(
name: "IX_Heats_RoundId",
table: "Heats",
column: "RoundId");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace SportsDivision.Infrastructure.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -309,7 +309,8 @@ namespace SportsDivision.Infrastructure.Migrations
b.HasKey("HeatId");
b.HasIndex("RoundId");
b.HasIndex("RoundId", "HeatNumber")
.IsUnique();
b.ToTable("Heats");
});
@@ -424,7 +425,8 @@ namespace SportsDivision.Infrastructure.Migrations
b.HasKey("HighJumpHeightId");
b.HasIndex("TournamentEventLevelId");
b.HasIndex("TournamentEventLevelId", "Height")
.IsUnique();
b.ToTable("HighJumpHeights");
});
@@ -533,7 +535,8 @@ namespace SportsDivision.Infrastructure.Migrations
b.HasKey("RoundId");
b.HasIndex("TournamentEventLevelId");
b.HasIndex("TournamentEventLevelId", "RoundOrder")
.IsUnique();
b.ToTable("Rounds");
});
@@ -662,7 +665,6 @@ namespace SportsDivision.Infrastructure.Migrations
.HasColumnType("date");
b.Property<string>("ExistingStudentId")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -690,7 +692,8 @@ namespace SportsDivision.Infrastructure.Migrations
b.HasKey("StudentId");
b.HasIndex("ExistingStudentId")
.IsUnique();
.IsUnique()
.HasFilter("\"ExistingStudentId\" IS NOT NULL");
b.HasIndex("SchoolId");

View File

@@ -9,6 +9,7 @@ public class EventRegistrationRepository : Repository<EventRegistration>, IEvent
{
public EventRegistrationRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<EventRegistration>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(r => r.TournamentEventLevelId == tournamentEventLevelId).Include(r => r.Student).ThenInclude(s => s!.School).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();
public async Task<IEnumerable<EventRegistration>> GetByTournamentAsync(int tournamentId) => await _dbSet.Where(r => r.TournamentEventLevel.TournamentId == tournamentId).Include(r => r.Student).ThenInclude(s => s!.School).ThenInclude(sc => sc!.Zone).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();
public async Task<IEnumerable<EventRegistration>> GetByStudentAsync(int studentId) => await _dbSet.Where(r => r.StudentId == studentId).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Tournament).Include(r => r.Score).ToListAsync();
public async Task<bool> IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId) => await _dbSet.AnyAsync(r => r.TournamentEventLevelId == tournamentEventLevelId && r.StudentId == studentId);
public async Task<IEnumerable<EventRegistration>> GetBySchoolAndTournamentAsync(int schoolId, int tournamentId) => await _dbSet.Where(r => r.Student != null && r.Student.SchoolId == schoolId && r.TournamentEventLevel.TournamentId == tournamentId).Include(r => r.Student).Include(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).Include(r => r.Score).ToListAsync();

View File

@@ -8,6 +8,9 @@ namespace SportsDivision.Infrastructure.Repositories;
public class HighJumpHeightRepository : Repository<HighJumpHeight>, IHighJumpHeightRepository
{
public HighJumpHeightRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<HighJumpHeight>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(h => h.TournamentEventLevelId == tournamentEventLevelId).Include(h => h.Attempts).OrderBy(h => h.SortOrder).ToListAsync();
public async Task<bool> HasAttemptsForRegistrationAsync(int eventRegistrationId) => await _context.Set<HighJumpAttempt>().AnyAsync(a => a.EventRegistrationId == eventRegistrationId);
// Ordered by the bar height itself: SortOrder is assigned by a read-then-write
// that can race under concurrent inserts, while Height is unique per event level.
public async Task<IEnumerable<HighJumpHeight>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(h => h.TournamentEventLevelId == tournamentEventLevelId).Include(h => h.Attempts).OrderBy(h => h.Height).ToListAsync();
public async Task<HighJumpHeight?> GetWithAttemptsAsync(int heightId) => await _dbSet.Include(h => h.Attempts).ThenInclude(a => a.EventRegistration).ThenInclude(r => r.Student).ThenInclude(s => s!.School).FirstOrDefaultAsync(h => h.HighJumpHeightId == heightId);
}

View File

@@ -21,7 +21,15 @@ public class Repository<T> : IRepository<T> where T : class
public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) => await _dbSet.Where(predicate).ToListAsync();
public async Task<T> AddAsync(T entity) { await _dbSet.AddAsync(entity); return entity; }
public async Task AddRangeAsync(IEnumerable<T> entities) => await _dbSet.AddRangeAsync(entities);
public void Update(T entity) => _dbSet.Update(entity);
public void Update(T entity)
{
// Entities the context already tracks have their changes detected at
// SaveChanges; calling DbSet.Update on them would mark every property (and
// the reachable graph) Modified, producing full-row UPDATEs. Only attach
// genuinely detached entities.
if (_context.Entry(entity).State == EntityState.Detached)
_dbSet.Update(entity);
}
public void Remove(T entity) => _dbSet.Remove(entity);
public async Task<bool> AnyAsync(Expression<Func<T, bool>> predicate) => await _dbSet.AnyAsync(predicate);
public async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null) => predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate);

View File

@@ -11,5 +11,22 @@ public class StudentRepository : Repository<Student>, IStudentRepository
public async Task<Student?> GetByExistingIdAsync(string existingStudentId) => await _dbSet.Include(s => s.School).FirstOrDefaultAsync(s => s.ExistingStudentId == existingStudentId);
public async Task<IEnumerable<Student>> GetBySchoolAsync(int schoolId) => await _dbSet.Where(s => s.SchoolId == schoolId).Include(s => s.School).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).ToListAsync();
public async Task<Student?> GetWithRegistrationsAsync(int studentId) => await _dbSet.Include(s => s.School).Include(s => s.Registrations).ThenInclude(r => r.TournamentEventLevel).ThenInclude(t => t.Event).Include(s => s.Registrations).ThenInclude(r => r.TournamentEventLevel).ThenInclude(t => t.EventLevel).FirstOrDefaultAsync(s => s.StudentId == studentId);
public async Task<IEnumerable<Student>> SearchAsync(string searchTerm) => await _dbSet.Include(s => s.School).Where(s => s.FirstName.Contains(searchTerm) || s.LastName.Contains(searchTerm) || s.ExistingStudentId.Contains(searchTerm)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName).Take(50).ToListAsync();
public async Task<(IReadOnlyList<Student> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize)
{
IQueryable<Student> query = _dbSet.Include(s => s.School);
if (schoolId.HasValue)
query = query.Where(s => s.SchoolId == schoolId.Value);
if (!string.IsNullOrWhiteSpace(searchTerm))
query = query.Where(s => s.FirstName.Contains(searchTerm)
|| s.LastName.Contains(searchTerm)
|| (s.ExistingStudentId != null && s.ExistingStudentId.Contains(searchTerm)));
var total = await query.CountAsync();
query = query.OrderBy(s => s.LastName).ThenBy(s => s.FirstName);
if (pageSize > 0)
query = query.Skip((page - 1) * pageSize).Take(pageSize);
return (await query.ToListAsync(), total);
}
}

View File

@@ -8,6 +8,7 @@ namespace SportsDivision.Infrastructure.Repositories;
public class TournamentEventLevelRepository : Repository<TournamentEventLevel>, ITournamentEventLevelRepository
{
public TournamentEventLevelRepository(ApplicationDbContext context) : base(context) { }
public async Task<IEnumerable<TournamentEventLevel>> GetByCategoryAsync(Domain.Enums.EventCategory category) => await _dbSet.Where(t => t.Event.Category == category && !t.Tournament.IsArchived).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).Include(t => t.Registrations).OrderByDescending(t => t.Tournament.StartDate).ThenBy(t => t.EventLevel.SortOrder).ThenBy(t => t.Event.Name).ToListAsync();
public async Task<TournamentEventLevel?> GetWithRegistrationsAsync(int id) => await _dbSet.Include(t => t.Registrations).ThenInclude(r => r.Student).ThenInclude(s => s!.School).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).FirstOrDefaultAsync(t => t.TournamentEventLevelId == id);
public async Task<TournamentEventLevel?> GetWithRoundsAsync(int id) => await _dbSet.Include(t => t.Rounds).ThenInclude(r => r.Heats).ThenInclude(h => h.HeatLanes).ThenInclude(hl => hl.EventRegistration).ThenInclude(r => r.Student).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Tournament).FirstOrDefaultAsync(t => t.TournamentEventLevelId == id);
public async Task<IEnumerable<TournamentEventLevel>> GetByTournamentAsync(int tournamentId) => await _dbSet.Where(t => t.TournamentId == tournamentId).Include(t => t.Event).Include(t => t.EventLevel).Include(t => t.Registrations).Include(t => t.Rounds).OrderBy(t => t.EventLevel.SortOrder).ThenBy(t => t.Event.Name).ToListAsync();

View File

@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using SportsDivision.Domain.Entities;
using SportsDivision.Domain.Enums;
using SportsDivision.Infrastructure.Data;
@@ -12,12 +14,21 @@ public class DatabaseSeeder
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager;
private readonly IConfiguration _configuration;
private readonly ILogger<DatabaseSeeder> _logger;
public DatabaseSeeder(ApplicationDbContext context, UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager)
public DatabaseSeeder(
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
IConfiguration configuration,
ILogger<DatabaseSeeder> logger)
{
_context = context;
_userManager = userManager;
_roleManager = roleManager;
_configuration = configuration;
_logger = logger;
}
public async Task SeedAsync()
@@ -43,18 +54,41 @@ public class DatabaseSeeder
private async Task SeedAdminUserAsync()
{
if (await _userManager.FindByEmailAsync("admin@sportsdivision.dm") != null) return;
// The initial admin's credentials come from configuration
// (SeedAdmin:Email / SeedAdmin:Password, e.g. the SeedAdmin__Password
// environment variable) so no password lives in the repository.
var email = _configuration["SeedAdmin:Email"] ?? "admin@sportsdivision.dm";
if (await _userManager.FindByEmailAsync(email) != null) return;
var password = _configuration["SeedAdmin:Password"];
if (string.IsNullOrWhiteSpace(password))
{
_logger.LogWarning(
"No admin account exists and SeedAdmin:Password is not configured — skipping admin seeding. " +
"Set the SeedAdmin__Password environment variable and restart to create the initial admin.");
return;
}
var admin = new ApplicationUser
{
UserName = "admin@sportsdivision.dm",
Email = "admin@sportsdivision.dm",
UserName = email,
Email = email,
FirstName = "System",
LastName = "Administrator",
EmailConfirmed = true,
IsActive = true
};
var result = await _userManager.CreateAsync(admin, "Admin@123!");
if (result.Succeeded) await _userManager.AddToRoleAsync(admin, "Admin");
var result = await _userManager.CreateAsync(admin, password);
if (result.Succeeded)
{
await _userManager.AddToRoleAsync(admin, "Admin");
_logger.LogInformation("Seeded initial admin account {Email}. Change its password after first sign-in.", email);
}
else
{
_logger.LogError("Failed to seed the admin account: {Errors}",
string.Join("; ", result.Errors.Select(e => e.Description)));
}
}
private async Task SeedZonesAsync()
@@ -175,8 +209,8 @@ public class DatabaseSeeder
new() { Name = "Under 20 Girls", Sex = Sex.Female, MaxAge = 19, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 14 },
new() { Name = "Under 21 Boys", Sex = Sex.Male, MaxAge = 20, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 15 },
new() { Name = "Under 21 Girls", Sex = Sex.Female, MaxAge = 20, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 16 },
new() { Name = "Open Boys", Sex = Sex.Male, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 17 },
new() { Name = "Open Girls", Sex = Sex.Female, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = true, SortOrder = 18 },
new() { Name = "Open Boys", Sex = Sex.Male, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = false, SortOrder = 17 },
new() { Name = "Open Girls", Sex = Sex.Female, SchoolLevel = SchoolLevel.Secondary, IsAgeBased = false, SortOrder = 18 },
});
await _context.SaveChangesAsync();
}

View File

@@ -34,17 +34,25 @@ public class AccountController : Controller
return View();
}
var result = await _signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: false);
// Deactivated accounts are rejected before sign-in rather than signed out
// afterwards; the generic message avoids confirming whether the account exists.
var user = await _userManager.FindByEmailAsync(email);
if (user is { IsActive: false })
{
ModelState.AddModelError("", "Invalid login attempt.");
return View();
}
var result = await _signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
var user = await _userManager.FindByEmailAsync(email);
if (user is { IsActive: false })
{
await _signInManager.SignOutAsync();
ModelState.AddModelError("", "This account has been deactivated. Please contact an administrator.");
return View();
}
return LocalRedirect(returnUrl ?? "/");
// A crafted absolute returnUrl falls back to home instead of throwing.
return Url.IsLocalUrl(returnUrl) ? Redirect(returnUrl!) : RedirectToAction("Index", "Home");
}
if (result.IsLockedOut)
{
ModelState.AddModelError("", "This account is temporarily locked after repeated failed attempts. Try again in a few minutes.");
return View();
}
ModelState.AddModelError("", "Invalid login attempt.");
return View();

View File

@@ -34,12 +34,14 @@ public class EventController : Controller
return View(events);
}
[Authorize(Roles = "Admin,Official")]
[HttpGet]
public IActionResult Create()
{
return View(new EventCreateDto());
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(EventCreateDto dto)
@@ -62,6 +64,7 @@ public class EventController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
@@ -93,6 +96,7 @@ public class EventController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(EventUpdateDto dto)
@@ -119,6 +123,7 @@ public class EventController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)

View File

@@ -6,7 +6,8 @@ using SportsDivision.Domain.Enums;
namespace SportsDivision.Web.Controllers;
[Authorize]
// Recording times, marks and advancement is restricted to meet officials.
[Authorize(Roles = "Admin,Official")]
public class FieldEventController : Controller
{
private readonly IScoringService _scoringService;
@@ -35,6 +36,15 @@ public class FieldEventController : Controller
}
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
var tel = await _tournamentService.GetEventLevelByIdAsync(tournamentEventLevelId);
if (tel != null)
{
ViewBag.EventName = tel.EventName;
ViewBag.LevelName = tel.EventLevelName;
ViewBag.TournamentName = tel.TournamentName;
}
return View(registrations);
}
@@ -42,9 +52,16 @@ public class FieldEventController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> RecordScore(ScoreCreateDto dto, int tournamentEventLevelId)
{
var recordedBy = User.Identity?.Name ?? "Unknown";
await _scoringService.RecordScoreAsync(dto, recordedBy);
TempData["SuccessMessage"] = "Performance recorded.";
try
{
var recordedBy = User.Identity?.Name ?? "Unknown";
await _scoringService.RecordScoreAsync(dto, recordedBy);
TempData["SuccessMessage"] = "Performance recorded.";
}
catch (KeyNotFoundException)
{
return NotFound();
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
@@ -52,8 +69,20 @@ public class FieldEventController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> CalculateScores(int tournamentEventLevelId)
{
var recordedBy = User.Identity?.Name ?? "Unknown";
await _scoringService.CalculateFinalScoresAsync(tournamentEventLevelId, recordedBy);
try
{
var recordedBy = User.Identity?.Name ?? "Unknown";
await _scoringService.CalculateFinalScoresAsync(tournamentEventLevelId, recordedBy);
TempData["SuccessMessage"] = "Points calculated.";
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
@@ -61,7 +90,15 @@ public class FieldEventController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> CalculatePlacements(int tournamentEventLevelId)
{
await _scoringService.CalculatePlacementsAsync(tournamentEventLevelId);
try
{
await _scoringService.CalculatePlacementsAsync(tournamentEventLevelId);
TempData["SuccessMessage"] = "Placements calculated.";
}
catch (KeyNotFoundException)
{
return NotFound();
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
}

View File

@@ -6,7 +6,8 @@ using SportsDivision.Domain.Enums;
namespace SportsDivision.Web.Controllers;
[Authorize]
// Recording times, marks and advancement is restricted to meet officials.
[Authorize(Roles = "Admin,Official")]
public class HighJumpController : Controller
{
private readonly IHighJumpService _highJumpService;
@@ -40,6 +41,15 @@ public class HighJumpController : Controller
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
ViewBag.Registrations = registrations;
var tel = await _tournamentService.GetEventLevelByIdAsync(tournamentEventLevelId);
if (tel != null)
{
ViewBag.EventName = tel.EventName;
ViewBag.LevelName = tel.EventLevelName;
ViewBag.TournamentName = tel.TournamentName;
}
return View(heights);
}
@@ -49,10 +59,18 @@ public class HighJumpController : Controller
{
if (!ModelState.IsValid)
{
TempData["ErrorMessage"] = "The height could not be added — check the value and try again.";
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = dto.TournamentEventLevelId });
}
await _highJumpService.AddHeightAsync(dto);
try
{
await _highJumpService.AddHeightAsync(dto);
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = dto.TournamentEventLevelId });
}
@@ -60,7 +78,14 @@ public class HighJumpController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveHeight(int heightId, int tournamentEventLevelId)
{
await _highJumpService.RemoveHeightAsync(heightId);
try
{
await _highJumpService.RemoveHeightAsync(heightId);
}
catch (KeyNotFoundException)
{
return NotFound();
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
@@ -68,7 +93,24 @@ public class HighJumpController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> RecordAttempt(HighJumpAttemptUpdateDto dto, int tournamentEventLevelId)
{
await _highJumpService.RecordAttemptAsync(dto);
// A blank result would bind to the enum default and be recorded as a clearance.
if (!ModelState.IsValid)
{
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
try
{
await _highJumpService.RecordAttemptAsync(dto);
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}

View File

@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using SportsDivision.Application.DTOs;
using SportsDivision.Application.Interfaces;
using SportsDivision.Infrastructure.Identity;
namespace SportsDivision.Web.Controllers;
@@ -11,31 +13,56 @@ public class RegistrationController : Controller
private readonly IRegistrationService _registrationService;
private readonly ITournamentService _tournamentService;
private readonly IStudentService _studentService;
private readonly UserManager<ApplicationUser> _userManager;
public RegistrationController(
IRegistrationService registrationService,
ITournamentService tournamentService,
IStudentService studentService)
IStudentService studentService,
UserManager<ApplicationUser> userManager)
{
_registrationService = registrationService;
_tournamentService = tournamentService;
_studentService = studentService;
_userManager = userManager;
}
/// <summary>
/// Coaches and principals act only for their own school; admins and officials
/// are unrestricted. Returns an error message, or null when allowed.
/// </summary>
private async Task<string?> CheckSchoolScopeAsync(int? studentId)
{
if (User.IsInRole("Admin") || User.IsInRole("Official")) return null;
var user = await _userManager.GetUserAsync(User);
if (user?.SchoolId == null)
return "Your account is not linked to a school — ask an administrator to set one.";
if (!studentId.HasValue) return null; // no student chosen yet; eligibility handles it
var student = await _studentService.GetByIdAsync(studentId.Value);
if (student == null || student.SchoolId != user.SchoolId)
return "You can only manage registrations for students of your own school.";
return null;
}
public async Task<IActionResult> Index(int tournamentEventLevelId)
{
var registrations = await _registrationService.GetByTournamentEventLevelAsync(tournamentEventLevelId);
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
await PopulateEventLevelViewBag(tournamentEventLevelId);
return View(registrations);
}
[Authorize(Roles = "Admin,Official,Coach,Principal")]
[HttpGet]
public async Task<IActionResult> Register(int tournamentEventLevelId, int? studentId)
{
var students = await _studentService.GetAllAsync();
ViewBag.Students = students;
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
await PopulateEventLevelViewBag(tournamentEventLevelId);
if (studentId.HasValue)
{
@@ -54,15 +81,22 @@ public class RegistrationController : Controller
return View(dto);
}
[Authorize(Roles = "Admin,Official,Coach,Principal")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Register(EventRegistrationCreateDto dto)
{
if (!ModelState.IsValid)
{
var students = await _studentService.GetAllAsync();
ViewBag.Students = students;
ViewBag.TournamentEventLevelId = dto.TournamentEventLevelId;
await PopulateRegisterViewBags(dto.TournamentEventLevelId);
return View(dto);
}
var scopeError = await CheckSchoolScopeAsync(dto.StudentId);
if (scopeError != null)
{
TempData["ErrorMessage"] = scopeError;
await PopulateRegisterViewBags(dto.TournamentEventLevelId);
return View(dto);
}
@@ -80,17 +114,43 @@ public class RegistrationController : Controller
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
var students = await _studentService.GetAllAsync();
ViewBag.Students = students;
ViewBag.TournamentEventLevelId = dto.TournamentEventLevelId;
await PopulateRegisterViewBags(dto.TournamentEventLevelId);
return View(dto);
}
}
private async Task PopulateRegisterViewBags(int tournamentEventLevelId)
{
ViewBag.Students = await _studentService.GetAllAsync();
ViewBag.TournamentEventLevelId = tournamentEventLevelId;
await PopulateEventLevelViewBag(tournamentEventLevelId);
}
private async Task PopulateEventLevelViewBag(int tournamentEventLevelId)
{
var tel = await _tournamentService.GetEventLevelByIdAsync(tournamentEventLevelId);
if (tel != null)
{
ViewBag.EventName = tel.EventName;
ViewBag.LevelName = tel.EventLevelName;
}
}
[Authorize(Roles = "Admin,Official,Coach,Principal")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Unregister(int eventRegistrationId, int tournamentEventLevelId)
{
var registration = await _registrationService.GetByIdAsync(eventRegistrationId);
if (registration == null) return NotFound();
var scopeError = await CheckSchoolScopeAsync(registration.StudentId);
if (scopeError != null)
{
TempData["ErrorMessage"] = scopeError;
return RedirectToAction(nameof(Index), new { tournamentEventLevelId });
}
try
{
await _registrationService.UnregisterAsync(eventRegistrationId);

View File

@@ -26,7 +26,9 @@ public class ReportController : Controller
[HttpGet]
public async Task<IActionResult> Index()
{
var tournaments = await _tournamentService.GetAllAsync();
// Reporting is retrospective: archived tournaments must stay selectable,
// otherwise archiving a season silently removes access to its reports.
var tournaments = await _tournamentService.GetAllAsync(includeArchived: true);
ViewBag.Tournaments = tournaments;
return View(tournaments);
}

View File

@@ -62,6 +62,7 @@ public class SchoolController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpGet]
public async Task<IActionResult> Create()
{
@@ -69,6 +70,7 @@ public class SchoolController : Controller
return View(new SchoolCreateDto());
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(SchoolCreateDto dto)
@@ -93,6 +95,7 @@ public class SchoolController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
@@ -123,6 +126,7 @@ public class SchoolController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(SchoolUpdateDto dto)
@@ -151,6 +155,7 @@ public class SchoolController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)

View File

@@ -9,32 +9,109 @@ namespace SportsDivision.Web.Controllers;
public class ScoringConfigController : Controller
{
private readonly IScoringService _scoringService;
private readonly IEventService _eventService;
public ScoringConfigController(IScoringService scoringService)
public ScoringConfigController(IScoringService scoringService, IEventService eventService)
{
_scoringService = scoringService;
_eventService = eventService;
}
[HttpGet]
public async Task<IActionResult> Index()
{
var constants = await _scoringService.GetScoringConstantsAsync();
var constants = (await _scoringService.GetScoringConstantsAsync()).ToList();
var placementConfigs = await _scoringService.GetPlacementPointConfigsAsync();
ViewBag.ScoringConstants = constants;
ViewBag.PlacementPointConfigs = placementConfigs;
// Events that have no scoring constant yet — offered in the "Add" form so
// the 12 seeded events without constants can be configured in-app.
var eventsWithConstant = constants.Select(c => c.EventId).ToHashSet();
ViewBag.EventsWithoutConstant = (await _eventService.GetAllAsync())
.Where(e => !eventsWithConstant.Contains(e.EventId))
.OrderBy(e => e.Name)
.ToList();
return View(constants);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateScoringConstant(ScoringConstantCreateDto dto)
{
if (!ModelState.IsValid || dto.EventId <= 0)
{
TempData["ErrorMessage"] = "The scoring constant could not be added — check the values and try again.";
return RedirectToAction(nameof(Index));
}
try
{
await _scoringService.CreateScoringConstantAsync(dto);
TempData["SuccessMessage"] = "Scoring constant added.";
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(Index));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateScoringConstant(ScoringConstantUpdateDto dto)
{
if (!ModelState.IsValid)
{
TempData["ErrorMessage"] = "The scoring constant could not be saved — check the values and try again.";
return RedirectToAction(nameof(Index));
}
await _scoringService.UpdateScoringConstantAsync(dto);
TempData["SuccessMessage"] = "Scoring constant saved.";
return RedirectToAction(nameof(Index));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteScoringConstant(int scoringConstantId)
{
try
{
await _scoringService.DeleteScoringConstantAsync(scoringConstantId);
TempData["SuccessMessage"] = "Scoring constant removed.";
}
catch (KeyNotFoundException)
{
return NotFound();
}
return RedirectToAction(nameof(Index));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreatePlacementPointConfig(PlacementPointConfigCreateDto dto)
{
if (!ModelState.IsValid)
{
TempData["ErrorMessage"] = "The placement points could not be added — check the values and try again.";
return RedirectToAction(nameof(Index));
}
try
{
await _scoringService.CreatePlacementPointConfigAsync(dto);
TempData["SuccessMessage"] = "Placement points added.";
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(Index));
}
@@ -44,10 +121,28 @@ public class ScoringConfigController : Controller
{
if (!ModelState.IsValid)
{
TempData["ErrorMessage"] = "The placement points could not be saved — check the values and try again.";
return RedirectToAction(nameof(Index));
}
await _scoringService.UpdatePlacementPointConfigAsync(dto);
TempData["SuccessMessage"] = "Placement points saved.";
return RedirectToAction(nameof(Index));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeletePlacementPointConfig(int placementPointConfigId)
{
try
{
await _scoringService.DeletePlacementPointConfigAsync(placementPointConfigId);
TempData["SuccessMessage"] = "Placement points removed.";
}
catch (KeyNotFoundException)
{
return NotFound();
}
return RedirectToAction(nameof(Index));
}
}

View File

@@ -20,26 +20,24 @@ public class StudentController : Controller
public async Task<IActionResult> Index(int? schoolId, string? search, int page = 1, int pageSize = PaginationHelper.PageSize)
{
IEnumerable<StudentDto> students;
pageSize = PaginationHelper.NormalizePageSize(pageSize);
if (page < 1) page = 1;
if (!string.IsNullOrWhiteSpace(search))
var (students, total) = await _studentService.GetPagedAsync(schoolId, search, page, pageSize);
// A stale page number past the end (e.g. after narrowing a filter) is clamped
// and re-queried so the user still sees results.
var clampedPage = this.SetPagingMetadata(page, total, pageSize);
if (clampedPage != page)
{
students = await _studentService.SearchAsync(search);
}
else if (schoolId.HasValue)
{
students = await _studentService.GetBySchoolAsync(schoolId.Value);
}
else
{
students = await _studentService.GetAllAsync();
(students, _) = await _studentService.GetPagedAsync(schoolId, search, clampedPage, pageSize);
}
await PopulateSchoolsViewBag();
ViewBag.SelectedSchoolId = schoolId;
ViewBag.SearchTerm = search;
return View(this.Page(students, page, pageSize));
return View(students);
}
public async Task<IActionResult> Details(int id)
@@ -60,6 +58,7 @@ public class StudentController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpGet]
public async Task<IActionResult> Create()
{
@@ -67,6 +66,7 @@ public class StudentController : Controller
return View(new StudentCreateDto());
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(StudentCreateDto dto)
@@ -91,6 +91,7 @@ public class StudentController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
@@ -123,6 +124,7 @@ public class StudentController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(StudentUpdateDto dto)
@@ -151,6 +153,7 @@ public class StudentController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)

View File

@@ -61,12 +61,14 @@ public class TournamentController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpGet]
public IActionResult Create()
{
return View(new TournamentCreateDto());
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(TournamentCreateDto dto)
@@ -89,6 +91,7 @@ public class TournamentController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpGet]
public async Task<IActionResult> Edit(int id)
{
@@ -118,6 +121,7 @@ public class TournamentController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(TournamentUpdateDto dto)
@@ -144,6 +148,7 @@ public class TournamentController : Controller
}
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
@@ -165,6 +170,7 @@ public class TournamentController : Controller
return RedirectToAction(nameof(Index));
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddEventLevel(TournamentEventLevelCreateDto dto)
@@ -186,6 +192,7 @@ public class TournamentController : Controller
return RedirectToAction(nameof(Details), new { id = dto.TournamentId });
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveEventLevel(int tournamentEventLevelId, int id)
@@ -207,6 +214,7 @@ public class TournamentController : Controller
return RedirectToAction(nameof(Details), new { id });
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateStatus(int id, TournamentStatus status)
@@ -228,6 +236,7 @@ public class TournamentController : Controller
return RedirectToAction(nameof(Details), new { id });
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Archive(int id)
@@ -249,6 +258,7 @@ public class TournamentController : Controller
return RedirectToAction(nameof(Index));
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Unarchive(int id)
@@ -270,6 +280,7 @@ public class TournamentController : Controller
return RedirectToAction(nameof(Index));
}
[Authorize(Roles = "Admin,Official")]
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleAgeWaiver(int tournamentEventLevelId, int id)

View File

@@ -6,7 +6,8 @@ using SportsDivision.Domain.Enums;
namespace SportsDivision.Web.Controllers;
[Authorize]
// Recording times, marks and advancement is restricted to meet officials.
[Authorize(Roles = "Admin,Official")]
public class TrackEventController : Controller
{
private readonly IHeatManagementService _heatManagementService;
@@ -80,10 +81,18 @@ public class TrackEventController : Controller
{
if (!ModelState.IsValid)
{
TempData["ErrorMessage"] = "The round could not be created — check the values and try again.";
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = dto.TournamentEventLevelId });
}
await _heatManagementService.CreateRoundAsync(dto);
try
{
await _heatManagementService.CreateRoundAsync(dto);
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(Index), new { tournamentEventLevelId = dto.TournamentEventLevelId });
}
@@ -91,7 +100,19 @@ public class TrackEventController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> SeedHeats(int roundId, SeedingMethod method, int lanesPerHeat = 8)
{
await _heatManagementService.SeedHeatsAsync(roundId, method, lanesPerHeat);
try
{
await _heatManagementService.SeedHeatsAsync(roundId, method, lanesPerHeat);
TempData["SuccessMessage"] = "Heats seeded.";
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (InvalidOperationException ex)
{
TempData["ErrorMessage"] = ex.Message;
}
return RedirectToAction(nameof(ManageRound), new { roundId });
}
@@ -132,10 +153,10 @@ public class TrackEventController : Controller
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CompleteHeat(int heatId)
public async Task<IActionResult> CompleteHeat(int heatId, int roundId)
{
await _heatManagementService.CompleteHeatAsync(heatId);
return RedirectToAction(nameof(ManageRound), new { roundId = heatId });
return RedirectToAction(nameof(ManageRound), new { roundId });
}
[HttpPost]

View File

@@ -31,10 +31,20 @@ public class UserManagementController : Controller
var schools = await _schoolService.GetAllAsync();
var schoolLookup = schools.ToDictionary(s => s.SchoolId, s => s.Name);
// One query per role instead of one per user.
var roleNames = await _roleManager.Roles.Select(r => r.Name!).ToListAsync();
var roleByUserId = new Dictionary<string, string>();
foreach (var roleName in roleNames)
{
foreach (var member in await _userManager.GetUsersInRoleAsync(roleName))
{
roleByUserId.TryAdd(member.Id, roleName);
}
}
var userDtos = new List<UserDto>();
foreach (var user in users)
{
var roles = await _userManager.GetRolesAsync(user);
var dto = new UserDto
{
Id = user.Id,
@@ -42,7 +52,7 @@ public class UserManagementController : Controller
FirstName = user.FirstName,
LastName = user.LastName,
FullName = user.FullName,
Role = roles.FirstOrDefault() ?? "None",
Role = roleByUserId.GetValueOrDefault(user.Id, "None"),
SchoolId = user.SchoolId,
SchoolName = user.SchoolId.HasValue && schoolLookup.TryGetValue(user.SchoolId.Value, out var name) ? name : null,
IsActive = user.IsActive
@@ -152,6 +162,21 @@ public class UserManagementController : Controller
var user = await _userManager.FindByIdAsync(dto.Id);
if (user == null) return NotFound();
// Guard against locking the system out of administration: an admin may not
// deactivate or demote themselves, and the last active Admin must remain.
var currentRoles0 = await _userManager.GetRolesAsync(user);
var losesAdmin = currentRoles0.Contains("Admin") && dto.Role != "Admin";
if (user.Id == _userManager.GetUserId(User) && (!dto.IsActive || losesAdmin))
{
TempData["ErrorMessage"] = "You cannot deactivate or demote your own account.";
return RedirectToAction(nameof(Index));
}
if ((!dto.IsActive || losesAdmin) && await IsLastActiveAdminAsync(user))
{
TempData["ErrorMessage"] = "This is the last active Admin account — it cannot be deactivated or demoted.";
return RedirectToAction(nameof(Index));
}
user.FirstName = dto.FirstName;
user.LastName = dto.LastName;
user.SchoolId = dto.SchoolId;
@@ -195,6 +220,20 @@ public class UserManagementController : Controller
var user = await _userManager.FindByIdAsync(id);
if (user == null) return NotFound();
if (user.IsActive)
{
if (user.Id == _userManager.GetUserId(User))
{
TempData["ErrorMessage"] = "You cannot deactivate your own account.";
return RedirectToAction(nameof(Index));
}
if (await IsLastActiveAdminAsync(user))
{
TempData["ErrorMessage"] = "This is the last active Admin account — it cannot be deactivated.";
return RedirectToAction(nameof(Index));
}
}
user.IsActive = !user.IsActive;
await _userManager.UpdateAsync(user);
@@ -202,6 +241,14 @@ public class UserManagementController : Controller
return RedirectToAction(nameof(Index));
}
/// <summary>True when <paramref name="user"/> is an active Admin and no other active Admin exists.</summary>
private async Task<bool> IsLastActiveAdminAsync(ApplicationUser user)
{
if (!user.IsActive || !await _userManager.IsInRoleAsync(user, "Admin")) return false;
var admins = await _userManager.GetUsersInRoleAsync("Admin");
return !admins.Any(a => a.IsActive && a.Id != user.Id);
}
[HttpGet]
public async Task<IActionResult> ResetPassword(string id)
{

View File

@@ -34,4 +34,27 @@ public static class PaginationHelper
if (showAll) return list.ToList();
return list.Skip((page - 1) * pageSize).Take(pageSize).ToList();
}
/// <summary>
/// Records paging metadata for data that was already paged at the database
/// (see <see cref="Domain.Interfaces.IStudentRepository.GetPagedAsync"/>), without
/// materialising the full result set. Returns the clamped page number.
/// </summary>
public static int SetPagingMetadata(this Controller controller, int page, int totalItems, int pageSize)
{
var showAll = pageSize <= 0;
var totalPages = showAll ? 1 : (int)Math.Ceiling(totalItems / (double)pageSize);
if (page < 1) page = 1;
if (totalPages > 0 && page > totalPages) page = totalPages;
controller.ViewData["Page"] = page;
controller.ViewData["TotalPages"] = totalPages;
controller.ViewData["TotalItems"] = totalItems;
controller.ViewData["PageSize"] = pageSize; // 0 == All
return page;
}
/// <summary>Coerces a requested page size to one of the offered choices.</summary>
public static int NormalizePageSize(int pageSize) =>
PageSizeOptions.Contains(pageSize) ? pageSize : PageSize;
}

View File

@@ -1,3 +1,5 @@
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.HttpOverrides;
using SportsDivision.Application;
using SportsDivision.Infrastructure;
using SportsDivision.Infrastructure.Seeding;
@@ -8,9 +10,20 @@ QuestPdfLicenseInitializer.EnsureInitialized();
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// TLS terminates at the reverse proxy (Caddy); trust its X-Forwarded-* headers so
// Request.IsHttps is correct and UseHttpsRedirection doesn't loop. The proxy's
// address inside the Docker network isn't fixed, hence the cleared known lists.
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
var app = builder.Build();
// Seed database
@@ -20,6 +33,8 @@ using (var scope = app.Services.CreateScope())
await seeder.SeedAsync();
}
app.UseForwardedHeaders();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Home/Error");

View File

@@ -48,8 +48,10 @@ public static class ReportExcelExporter
return ms.ToArray();
}
private static void SetInt(IXLCell cell, int? v) { if (v.HasValue) cell.Value = v.Value; else cell.Value = "-"; }
private static void SetDec(IXLCell cell, decimal? v) { if (v.HasValue) cell.Value = v.Value; else cell.Value = "-"; }
// Missing values stay blank so the column keeps a uniform numeric type —
// writing "-" would make Excel treat the column as text for sorting/aggregation.
private static void SetInt(IXLCell cell, int? v) { if (v.HasValue) cell.Value = v.Value; }
private static void SetDec(IXLCell cell, decimal? v) { if (v.HasValue) cell.Value = v.Value; }
public static byte[] PopularEvents(IEnumerable<PopularEventsReportDto> data, string tournamentName)
{

View File

@@ -1,3 +0,0 @@
namespace SportsDivision.Web.ViewModels;
// Placeholder to establish namespace — will be replaced with actual ViewModels

View File

@@ -1,20 +1,33 @@
@model IEnumerable<EventDto>
@{
ViewData["Title"] = "Events";
var selectedCategory = ViewBag.SelectedCategory as EventCategory?;
var categories = selectedCategory.HasValue
? new[] { selectedCategory.Value }
: Enum.GetValues<EventCategory>();
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Events</h2>
<a asp-action="Create" class="btn btn-primary">Add Event</a>
<div class="d-flex gap-2 align-items-center">
<form method="get" class="d-flex gap-2">
<select name="category" class="form-select form-select-sm" onchange="this.form.submit()">
<option value="">All Categories</option>
@foreach (var c in Enum.GetValues<EventCategory>())
{
<option value="@c" selected="@(selectedCategory == c)">@c</option>
}
</select>
</form>
<a asp-action="Create" class="btn btn-primary">Add Event</a>
</div>
</div>
<partial name="_Notification" />
<div class="row">
@foreach (var category in new[] { "Track", "Field", "HighJump" })
@foreach (var category in categories)
{
var catEvents = Model.Where(e => e.Category.ToString() == category);
if (!catEvents.Any()) continue;
var catEvents = Model.Where(e => e.Category == category);
if (!catEvents.Any()) { continue; }
<div class="col-md-4">
<div class="card shadow-sm mb-3">
<div class="card-header bg-dark text-white"><h6 class="mb-0">@category Events</h6></div>
@@ -22,11 +35,19 @@
@foreach (var e in catEvents)
{
<li class="list-group-item d-flex justify-content-between align-items-center">
@e.Name
<span>
@e.Name
@if (e.IsRelay) { <span class="badge bg-info">Relay</span> }
@if (e.PrimarySchool) { <span class="badge bg-success">P</span> }
@if (e.SecondarySchool) { <span class="badge bg-primary">S</span> }
@if (!e.IsActive) { <span class="badge bg-secondary">Inactive</span> }
</span>
<span class="text-nowrap">
<a asp-action="Edit" asp-route-id="@e.EventId" class="btn btn-sm btn-outline-primary">Edit</a>
<form asp-action="Delete" asp-route-id="@e.EventId" method="post" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Delete @e.Name? This cannot be undone.')">Delete</button>
</form>
</span>
</li>
}

View File

@@ -23,7 +23,6 @@
</div>
</div>
<partial name="_Notification" />
<div class="card shadow-sm">
<div class="card-body p-0">

View File

@@ -28,8 +28,6 @@
</div>
</div>
<partial name="_Notification" />
<div class="card shadow-sm">
<div class="card-body p-0 table-responsive">
<table class="table table-bordered mb-0">
@@ -37,12 +35,13 @@
<tr>
<th>Student</th>
<th>School</th>
@foreach (var height in Model.OrderBy(h => h.SortOrder))
@foreach (var height in Model.OrderBy(h => h.Height))
{
<th class="text-center" style="min-width:80px">
@height.Height.ToString("0.00")m
<form asp-action="RemoveHeight" method="post" class="d-inline">
<input type="hidden" name="heightId" value="@height.HighJumpHeightId" />
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<button type="submit" class="btn btn-link btn-sm text-danger p-0" onclick="return confirm('Remove?')">x</button>
</form>
</th>
@@ -57,38 +56,30 @@
<tr>
<td>@reg.StudentName</td>
<td>@reg.SchoolName</td>
@foreach (var height in Model.OrderBy(h => h.SortOrder))
@foreach (var height in Model.OrderBy(h => h.Height))
{
var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == reg.EventRegistrationId);
<td class="text-center">
@if (attempt != null)
@* One control per attempt slot, pre-selected with the recorded
result, so attempts 2 and 3 stay editable after attempt 1 is
saved (and mistakes can be corrected). *@
@for (int i = 1; i <= 3; i++)
{
@foreach (var a in new[] { attempt.Attempt1, attempt.Attempt2, attempt.Attempt3 })
{
if (a == HighJumpAttemptResult.Clear) { <span class="text-success fw-bold">O</span> }
else if (a == HighJumpAttemptResult.Fail) { <span class="text-danger fw-bold">X</span> }
else if (a == HighJumpAttemptResult.Pass) { <span class="text-muted">-</span> }
}
@if (attempt.IsEliminated) { <br/><span class="badge bg-danger">OUT</span> }
}
else
{
@for (int i = 1; i <= 3; i++)
{
<form asp-action="RecordAttempt" method="post" class="d-inline">
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<input type="hidden" name="HighJumpHeightId" value="@height.HighJumpHeightId" />
<input type="hidden" name="EventRegistrationId" value="@reg.EventRegistrationId" />
<input type="hidden" name="AttemptNumber" value="@i" />
<select name="Result" onchange="this.form.submit()" class="form-select form-select-sm d-inline" style="width:50px">
<option value="">@i</option>
<option value="Clear">O</option>
<option value="Fail">X</option>
<option value="Pass">-</option>
</select>
</form>
}
var current = i == 1 ? attempt?.Attempt1 : i == 2 ? attempt?.Attempt2 : attempt?.Attempt3;
<form asp-action="RecordAttempt" method="post" class="d-inline">
<input type="hidden" name="tournamentEventLevelId" value="@telId" />
<input type="hidden" name="HighJumpHeightId" value="@height.HighJumpHeightId" />
<input type="hidden" name="EventRegistrationId" value="@reg.EventRegistrationId" />
<input type="hidden" name="AttemptNumber" value="@i" />
<select name="Result" onchange="this.form.submit()" class="form-select form-select-sm d-inline" style="width:52px">
<option value="" selected="@(current == null)">@i</option>
<option value="Clear" selected="@(current == HighJumpAttemptResult.Clear)">O</option>
<option value="Fail" selected="@(current == HighJumpAttemptResult.Fail)">X</option>
<option value="Pass" selected="@(current == HighJumpAttemptResult.Pass)">-</option>
</select>
</form>
}
@if (attempt?.IsEliminated == true) { <br/><span class="badge bg-danger">OUT</span> }
</td>
}
</tr>

View File

@@ -1,10 +1,10 @@
@model IEnumerable<EventRegistrationDto>
@{
ViewData["Title"] = "Student Registrations";
var studentName = ViewBag.StudentName as string;
var student = ViewBag.Student as StudentDto;
}
<h2>Registrations for @studentName</h2>
<h2>Registrations for @(student?.FullName ?? "student")</h2>
<hr />
<table class="table table-striped table-hover">

View File

@@ -17,7 +17,6 @@
}
</div>
<partial name="_Notification" />
<table class="table table-striped table-hover">
<thead class="table-dark">

View File

@@ -1,29 +1,55 @@
@model EventRegistrationCreateDto
@{
ViewData["Title"] = "Register Student";
var students = ViewBag.Students as IEnumerable<StudentDto>;
var telId = ViewBag.TournamentEventLevelId as int?;
var eventName = ViewBag.EventName as string;
var levelName = ViewBag.LevelName as string;
var isEligible = ViewBag.IsEligible as bool?;
var eligibilityReason = ViewBag.EligibilityReason as string;
var selectedStudentId = Model?.StudentId;
}
<h2>Register Student</h2>
<p class="text-muted">@eventName - @levelName</p>
@if (!string.IsNullOrEmpty(eventName))
{
<p class="text-muted">@eventName - @levelName</p>
}
<hr />
<partial name="_Notification" />
<div class="row">
<div class="col-md-6">
<form asp-action="Register" method="post">
<div asp-validation-summary="All" class="text-danger"></div>
<input type="hidden" name="TournamentEventLevelId" value="@telId" />
<div class="mb-3">
<label class="form-label">Student</label>
<select name="StudentId" class="form-select">
@* Re-request the page with the chosen student so eligibility is checked before submitting. *@
<select name="StudentId" class="form-select"
onchange="window.location = '@Url.Action("Register", new { tournamentEventLevelId = telId })&studentId=' + this.value">
<option value="">Select Student</option>
@if (students != null) { @foreach (var s in students) { <option value="@s.StudentId">@s.FullName (@s.SchoolName) - @s.Sex</option> } }
@if (students != null)
{
@foreach (var s in students)
{
<option value="@s.StudentId" selected="@(selectedStudentId == s.StudentId)">@s.FullName (@s.SchoolName) - @s.Sex</option>
}
}
</select>
</div>
<button type="submit" class="btn btn-primary">Register</button>
@if (isEligible == true)
{
<div class="alert alert-success py-2">
<i class="bi bi-check-circle"></i> This student is eligible for this event level.
</div>
}
else if (isEligible == false)
{
<div class="alert alert-danger py-2">
<i class="bi bi-x-circle"></i> Not eligible: @eligibilityReason
</div>
}
<button type="submit" class="btn btn-primary" disabled="@(isEligible == false)">Register</button>
<a asp-action="Index" asp-route-tournamentEventLevelId="@telId" class="btn btn-secondary">Cancel</a>
</form>
</div>

View File

@@ -26,7 +26,7 @@
{
<form asp-action="@report.Item1" method="get" class="d-flex gap-2">
<select name="tournamentId" class="form-select form-select-sm">
@foreach (var t in tournaments) { <option value="@t.TournamentId">@t.Name</option> }
@foreach (var t in tournaments) { <option value="@t.TournamentId">@t.Name@(t.IsArchived ? " (archived)" : "")</option> }
</select>
<button type="submit" class="btn btn-sm btn-primary text-nowrap">View</button>
</form>

View File

@@ -12,7 +12,6 @@
</div>
</div>
<partial name="_Notification" />
<div class="card shadow-sm" style="max-width: 600px;">
<div class="card-body">

View File

@@ -2,8 +2,8 @@
@{
ViewData["Title"] = "Schools";
var zones = ViewBag.Zones as IEnumerable<ZoneDto>;
var selectedZone = ViewBag.SelectedZone as int?;
var selectedLevel = ViewBag.SelectedLevel as string;
var selectedZone = ViewBag.SelectedZoneId as int?;
var selectedLevel = ViewBag.SelectedLevel as SchoolLevel?;
}
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -21,9 +21,9 @@
<div class="col-auto">
<select name="level" class="form-select form-select-sm">
<option value="">All Levels</option>
<option value="Primary" selected="@(selectedLevel == "Primary")">Primary</option>
<option value="Secondary" selected="@(selectedLevel == "Secondary")">Secondary</option>
<option value="College" selected="@(selectedLevel == "College")">College</option>
<option value="Primary" selected="@(selectedLevel == SchoolLevel.Primary)">Primary</option>
<option value="Secondary" selected="@(selectedLevel == SchoolLevel.Secondary)">Secondary</option>
<option value="College" selected="@(selectedLevel == SchoolLevel.College)">College</option>
</select>
</div>
<div class="col-auto">
@@ -31,7 +31,6 @@
</div>
</form>
<partial name="_Notification" />
<table class="table table-striped table-hover">
<thead class="table-dark">

View File

@@ -2,13 +2,12 @@
ViewData["Title"] = "Scoring Configuration";
var constants = ViewBag.ScoringConstants as IEnumerable<ScoringConstantDto>;
var placements = ViewBag.PlacementPointConfigs as IEnumerable<PlacementPointConfigDto>;
var eventsWithoutConstant = ViewBag.EventsWithoutConstant as IEnumerable<EventDto>;
}
<h2>Scoring Configuration</h2>
<hr />
<partial name="_Notification" />
<div class="row">
<div class="col-md-8">
<div class="card shadow-sm mb-4">
@@ -23,10 +22,13 @@
<form id="scf-@c.ScoringConstantId" asp-action="UpdateScoringConstant" method="post" class="d-none">
<input type="hidden" name="ScoringConstantId" value="@c.ScoringConstantId" />
</form>
<form id="scfd-@c.ScoringConstantId" asp-action="DeleteScoringConstant" method="post" class="d-none">
<input type="hidden" name="scoringConstantId" value="@c.ScoringConstantId" />
</form>
}
}
<table class="table table-hover mb-0">
<thead><tr><th>Event</th><th>A</th><th>B</th><th>C</th><th>Unit</th><th>Action</th></tr></thead>
<thead><tr><th>Event</th><th>A</th><th>B</th><th>C</th><th>Unit</th><th>Actions</th></tr></thead>
<tbody>
@if (constants != null)
{
@@ -38,13 +40,60 @@
<td><input type="number" step="0.1" name="B" value="@c.B" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:80px" /></td>
<td><input type="number" step="0.01" name="C" value="@c.C" form="scf-@c.ScoringConstantId" class="form-control form-control-sm" style="width:80px" /></td>
<td>@c.Unit</td>
<td><button type="submit" form="scf-@c.ScoringConstantId" class="btn btn-sm btn-outline-primary">Save</button></td>
<td class="text-nowrap">
<button type="submit" form="scf-@c.ScoringConstantId" class="btn btn-sm btn-outline-primary">Save</button>
<button type="submit" form="scfd-@c.ScoringConstantId" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Remove the scoring constant for @c.EventName? Its points can no longer be calculated until a new one is added.')">Delete</button>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="card-footer">
@if (eventsWithoutConstant != null && eventsWithoutConstant.Any())
{
<form asp-action="CreateScoringConstant" method="post" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-0">Event</label>
<select name="EventId" class="form-select form-select-sm" required>
<option value="">Select event…</option>
@foreach (var e in eventsWithoutConstant)
{
<option value="@e.EventId">@e.Name</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-0">A</label>
<input type="number" step="0.00001" name="A" class="form-control form-control-sm" style="width:100px" required />
</div>
<div class="col-auto">
<label class="form-label small mb-0">B</label>
<input type="number" step="0.1" name="B" class="form-control form-control-sm" style="width:80px" required />
</div>
<div class="col-auto">
<label class="form-label small mb-0">C</label>
<input type="number" step="0.01" name="C" class="form-control form-control-sm" style="width:80px" required />
</div>
<div class="col-auto">
<label class="form-label small mb-0">Unit</label>
<select name="Unit" class="form-select form-select-sm">
<option value="seconds">seconds</option>
<option value="metres">metres</option>
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-success">Add Constant</button>
</div>
</form>
}
else
{
<span class="text-muted small">Every event has a scoring constant.</span>
}
</div>
</div>
</div>
<div class="col-md-4">
@@ -61,10 +110,13 @@
<input type="hidden" name="PlacementPointConfigId" value="@p.PlacementPointConfigId" />
<input type="hidden" name="Placement" value="@p.Placement" />
</form>
<form id="ppfd-@p.PlacementPointConfigId" asp-action="DeletePlacementPointConfig" method="post" class="d-none">
<input type="hidden" name="placementPointConfigId" value="@p.PlacementPointConfigId" />
</form>
}
}
<table class="table table-hover mb-0">
<thead><tr><th>Place</th><th>Points</th><th>Action</th></tr></thead>
<thead><tr><th>Place</th><th>Points</th><th>Actions</th></tr></thead>
<tbody>
@if (placements != null)
{
@@ -73,13 +125,32 @@
<tr>
<td>#@p.Placement</td>
<td><input type="number" name="Points" value="@p.Points" form="ppf-@p.PlacementPointConfigId" class="form-control form-control-sm" style="width:60px" /></td>
<td><button type="submit" form="ppf-@p.PlacementPointConfigId" class="btn btn-sm btn-outline-primary">Save</button></td>
<td class="text-nowrap">
<button type="submit" form="ppf-@p.PlacementPointConfigId" class="btn btn-sm btn-outline-primary">Save</button>
<button type="submit" form="ppfd-@p.PlacementPointConfigId" class="btn btn-sm btn-outline-danger"
onclick="return confirm('Remove points for place #@p.Placement?')">Delete</button>
</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="card-footer">
<form asp-action="CreatePlacementPointConfig" method="post" class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-0">Place</label>
<input type="number" name="Placement" min="1" class="form-control form-control-sm" style="width:70px" required />
</div>
<div class="col-auto">
<label class="form-label small mb-0">Points</label>
<input type="number" name="Points" min="0" class="form-control form-control-sm" style="width:70px" required />
</div>
<div class="col-auto">
<button type="submit" class="btn btn-sm btn-success">Add</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@@ -8,7 +8,7 @@
<link rel="icon" href="~/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="~/apple-touch-icon.png" />
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.min.css" />
<style>
:root {
--help-primary: #003366;

View File

@@ -8,7 +8,7 @@
<link rel="icon" href="~/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="~/apple-touch-icon.png" />
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="~/lib/bootstrap-icons/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</head>
<body>

View File

@@ -1,23 +1,15 @@
@using Microsoft.AspNetCore.Identity
@using SportsDivision.Infrastructure.Identity
@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@using System.Security.Claims
@if (SignInManager.IsSignedIn(User))
@if (User.Identity?.IsAuthenticated == true)
{
var currentUser = await UserManager.GetUserAsync(User);
var initials = "";
var displayName = UserManager.GetUserName(User) ?? "User";
if (currentUser != null)
{
var first = !string.IsNullOrEmpty(currentUser.FirstName) ? currentUser.FirstName[0].ToString() : "";
var last = !string.IsNullOrEmpty(currentUser.LastName) ? currentUser.LastName[0].ToString() : "";
initials = (first + last).ToUpper();
if (!string.IsNullOrEmpty(currentUser.FirstName))
{
displayName = currentUser.FirstName;
}
}
@* Name and initials come from claims stamped into the cookie at sign-in
(AppClaimsPrincipalFactory) — no database query per page render. *@
var firstName = User.FindFirstValue(ClaimTypes.GivenName);
var lastName = User.FindFirstValue(ClaimTypes.Surname);
var displayName = !string.IsNullOrEmpty(firstName) ? firstName : (User.Identity.Name ?? "User");
var initials = (
(!string.IsNullOrEmpty(firstName) ? firstName[0].ToString() : "") +
(!string.IsNullOrEmpty(lastName) ? lastName[0].ToString() : "")).ToUpper();
if (string.IsNullOrEmpty(initials))
{
initials = displayName.Length > 0 ? displayName[0].ToString().ToUpper() : "U";

View File

@@ -1,6 +1,6 @@
@if (TempData["SuccessMessage"] != null)
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<div class="alert alert-success alert-dismissible fade show" role="alert" data-autodismiss="true">
@TempData["SuccessMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@@ -8,7 +8,7 @@
@if (TempData["ErrorMessage"] != null)
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<div class="alert alert-danger alert-dismissible fade show" role="alert" data-autodismiss="true">
@TempData["ErrorMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@@ -16,7 +16,9 @@
@if (TempData["WarningMessage"] != null)
{
<div class="alert alert-warning alert-dismissible fade show" role="alert" data-no-autodismiss="true">
@* Warnings stay visible: they carry information the official must act on
(e.g. a jump-off is required to decide a tie for first). *@
<div class="alert alert-warning alert-dismissible fade show" role="alert">
@TempData["WarningMessage"]
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
@@ -25,8 +27,9 @@
<script>
document.addEventListener('DOMContentLoaded', function () {
setTimeout(function () {
// Auto-dismiss transient alerts, but keep ones that need the user's attention.
var alerts = document.querySelectorAll('.alert:not([data-no-autodismiss])');
// Auto-dismiss only transient notifications rendered by this partial —
// never warnings, and never informational alerts that are page content.
var alerts = document.querySelectorAll('.alert[data-autodismiss]');
alerts.forEach(function (alert) {
var bsAlert = new bootstrap.Alert(alert);
bsAlert.close();

View File

@@ -0,0 +1,38 @@
@model StudentDto
@{
ViewData["Title"] = Model.FullName;
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>@Model.FullName</h2>
<div>
<a asp-action="Edit" asp-route-id="@Model.StudentId" class="btn btn-primary">Edit</a>
<a asp-controller="Registration" asp-action="ByStudent" asp-route-studentId="@Model.StudentId" class="btn btn-outline-info">Registrations</a>
<a asp-action="Index" class="btn btn-secondary">Back to Students</a>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Student ID</dt>
<dd class="col-sm-9">@(string.IsNullOrEmpty(Model.ExistingStudentId) ? "—" : Model.ExistingStudentId)</dd>
<dt class="col-sm-3">Date of Birth</dt>
<dd class="col-sm-9">@Model.DateOfBirth.ToString("yyyy-MM-dd")</dd>
<dt class="col-sm-3">Sex</dt>
<dd class="col-sm-9"><span class="badge @(Model.Sex == Sex.Male ? "bg-primary" : "bg-danger")">@Model.Sex</span></dd>
<dt class="col-sm-3">School</dt>
<dd class="col-sm-9">
<a asp-controller="School" asp-action="Details" asp-route-id="@Model.SchoolId">@Model.SchoolName</a>
</dd>
<dt class="col-sm-3">Status</dt>
<dd class="col-sm-9">
<span class="badge @(Model.IsActive ? "bg-success" : "bg-secondary")">@(Model.IsActive ? "Active" : "Inactive")</span>
</dd>
</dl>
</div>
</div>

View File

@@ -2,8 +2,8 @@
@{
ViewData["Title"] = "Students";
var schools = ViewBag.Schools as IEnumerable<SchoolDto>;
var selectedSchool = ViewBag.SelectedSchool as int?;
var search = ViewBag.Search as string;
var selectedSchool = ViewBag.SelectedSchoolId as int?;
var search = ViewBag.SearchTerm as string;
}
<div class="d-flex justify-content-between align-items-center mb-3">
@@ -26,7 +26,6 @@
</div>
</form>
<partial name="_Notification" />
<table class="table table-striped table-hover">
<thead class="table-dark">
@@ -49,6 +48,7 @@
<td><span class="badge @(s.Sex == Sex.Male ? "bg-primary" : "bg-danger")">@s.Sex</span></td>
<td>@s.SchoolName</td>
<td>
<a asp-action="Details" asp-route-id="@s.StudentId" class="btn btn-sm btn-outline-secondary">Details</a>
<a asp-action="Edit" asp-route-id="@s.StudentId" class="btn btn-sm btn-outline-primary">Edit</a>
<a asp-controller="Registration" asp-action="ByStudent" asp-route-studentId="@s.StudentId" class="btn btn-sm btn-outline-info">Registrations</a>
</td>

View File

@@ -90,7 +90,6 @@
</div>
</div>
<partial name="_Notification" />
<div class="row">
<div class="col-md-8">

View File

@@ -11,7 +11,6 @@
<a asp-action="Create" class="btn btn-primary">Create Tournament</a>
</div>
<partial name="_Notification" />
<form asp-action="Index" method="get" class="row g-2 align-items-end mb-3">
<div class="col-auto">

View File

@@ -29,7 +29,6 @@
}
</div>
<partial name="_Notification" />
<div class="card shadow-sm mb-3">
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
@@ -80,14 +79,21 @@
</td>
<td>
<a asp-action="ManageRound" asp-route-roundId="@r.RoundId" class="btn btn-sm btn-primary">Manage</a>
@if (r.Heats.Count == 0)
{
<form asp-action="SeedHeats" method="post" class="d-inline">
<input type="hidden" name="roundId" value="@r.RoundId" />
<input type="hidden" name="method" value="Random" />
<form asp-action="SeedHeats" method="post" class="d-inline">
<input type="hidden" name="roundId" value="@r.RoundId" />
<input type="hidden" name="method" value="Random" />
@if (r.Heats.Count == 0)
{
<button type="submit" class="btn btn-sm btn-outline-success">Seed Heats</button>
</form>
}
}
else
{
<button type="submit" class="btn btn-sm btn-outline-warning"
onclick="return confirm('Re-seed this round? Existing heats, lane assignments and any recorded times for this round will be replaced.')">
Re-seed
</button>
}
</form>
</td>
</tr>
}

View File

@@ -32,7 +32,6 @@
</div>
</div>
<partial name="_Notification" />
@if (!Model.Heats.Any())
{
@@ -113,6 +112,7 @@
</form>
<form id="complete-heat-@heat.HeatId" asp-action="CompleteHeat" method="post" style="display:none">
<input type="hidden" name="heatId" value="@heat.HeatId" />
<input type="hidden" name="roundId" value="@Model.RoundId" />
</form>
</div>
</div>

View File

@@ -1,6 +1,5 @@
@using SportsDivision.Web
@using SportsDivision.Web.Models
@using SportsDivision.Web.ViewModels
@using SportsDivision.Application.DTOs
@using SportsDivision.Domain.Enums
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

View File

@@ -4,5 +4,12 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=sportsdivision;Username=postgres;Password=CHANGE_ME"
},
"SeedAdmin": {
"Email": "admin@sportsdivision.dm",
"Password": ""
}
}

View File

@@ -5,13 +5,5 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=74.50.64.180;Port=5432;Database=sportsdivision_db;Username=postgres;Password=rG4eX5vU8kD4jY5k"
},
"EmailSettings": {
"GmailEmail": "pcgurudm@gmail.com",
"GmailPassword": "bbux tqjo lubq utss",
"DisplayName": "Sports Division"
}
"AllowedHosts": "*"
}

View File

@@ -7,11 +7,6 @@
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=sportsdivision;Username=postgres;Password=aN5eM6zM0nX4nX9j"
},
"EmailSettings": {
"GmailEmail": "pcgurudm@gmail.com",
"GmailPassword": "bbux tqjo lubq utss",
"DisplayName": "Sports Division"
"DefaultConnection": ""
}
}

View File

@@ -39,14 +39,6 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
// Auto-dismiss alerts after 5 seconds
setTimeout(function () {
var alerts = document.querySelectorAll('.alert');
alerts.forEach(function (alert) {
var bsAlert = bootstrap.Alert.getOrCreateInstance(alert);
if (bsAlert) {
bsAlert.close();
}
});
}, 5000);
// Notification auto-dismiss lives in _Notification.cshtml, which spares alerts
// marked data-no-autodismiss and leaves informational page content alone.
});

File diff suppressed because one or more lines are too long