diff --git a/.gitignore b/.gitignore index 691baf7..59ca47b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ Thumbs.db ## Secrets appsettings.*.local.json +.env diff --git a/Dockerfile b/Dockerfile index e785b27..58da0cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,6 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -EXPOSE 80 - # Copy project files and restore dependencies COPY src/SportsDivision.Domain/SportsDivision.Domain.csproj src/SportsDivision.Domain/ COPY src/SportsDivision.Application/SportsDivision.Application.csproj src/SportsDivision.Application/ @@ -23,6 +21,8 @@ WORKDIR /app ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_URLS=http://+:80 +EXPOSE 80 + COPY --from=build /app/out . ENTRYPOINT ["dotnet", "SportsDivision.Web.dll"] diff --git a/bug-fixes.md b/bug-fixes.md new file mode 100644 index 0000000..1be6397 --- /dev/null +++ b/bug-fixes.md @@ -0,0 +1,361 @@ +# Bug Findings & Fixes — SportsDivision + +Original scan: 2026-08-11 against `master` @ `78c526e` (45 findings, two sweeps). +**Fix pass completed 2026-08-11.** Every finding was re-verified against the code +before changes were made; **all 45 were confirmed real and accurately described** +(one correction of detail: the AutoMapper advisory is patched only in 15.1.1/16.1.1, +not in any 13.x/14.x — see #21). + +State after fixes: `dotnet build` succeeds, `dotnet test` passes **63/63** +(52 pre-existing + 11 new tests covering the fixed logic). A new EF migration +`SchemaIntegrityFixes` carries the schema changes (#13, #38) and is confirmed in +sync with the model. + +--- + +## ⚠️ Required manual actions (cannot be done from the repo) + +1. **Rotate the leaked credentials** (#15). The following were committed and are in + git history; removing them from HEAD (done) is not sufficient: + - Postgres superuser password for `74.50.64.180` (production) + - the local/dev Postgres password + - the Gmail app password for `pcgurudm@gmail.com` (revoke it — the mail feature + doesn't even exist) +2. **Provide secrets via environment at deploy time.** `docker-compose.yml` now + requires `DB_CONNECTION_STRING` (and optionally `SEED_ADMIN_PASSWORD`), e.g. from + an untracked `.env` next to the compose file on the VPS or injected by Jenkins. + The Jenkins deploy stage runs `docker compose up` from a fresh checkout, so until + a `.env` exists on the host path (or the pipeline exports the variable), deploys + will fail fast with a clear compose error. Local dev: set the connection string in + `appsettings.Development.json` (placeholder committed) or user-secrets. +3. **Change the admin password** if the seeded `admin@sportsdivision.dm` / + `Admin@123!` account exists in any database — the seeder no longer creates it + with a hardcoded password (#16), but existing rows keep the old one. +4. **Decide on git history purge** for the leaked secrets (e.g. `git filter-repo`). + Not done here because rewriting published history must be coordinated. +5. **Decide on AutoMapper 15.1.1** (#21): the DoS advisory is only patched in + 15.1.1+/16.1.1+, which are under the new RPL/commercial dual licence. Upgraded to + 14.0.0 (last version under the original licence); the NU1903 warning remains + deliberately visible. Exploitability here is negligible — the app only maps its + own bounded EF entity graphs, never attacker-controlled object graphs. + +--- + +## P1 — Correctness & crashes + +### 1. "Complete Heat" navigated to the wrong round (or 404) — **FIXED** ✅ +Verified: `CompleteHeat` redirected with `roundId = heatId`. +Fix: the hidden `complete-heat` form in `ManageRound.cshtml` now posts `roundId` +alongside `heatId`, and `TrackEventController.CompleteHeat(int heatId, int roundId)` +redirects with the real round id. + +### 2. FluentValidation never registered — **FIXED** ✅ +Verified: four validators existed with zero registration; every `ModelState.IsValid` +guard was unconditionally true. +Fix: `AddValidatorsFromAssembly(...)` in `AddApplication()` (new +`FluentValidation.DependencyInjectionExtensions` package reference) and +`AddFluentValidationAutoValidation()` in `Program.cs`. All four validators +(student, school, tournament incl. `EndDate >= StartDate`, registration) now run; +`Register.cshtml` gained a validation summary so registration rule failures are shown. + +### 3. NullReferenceException registering a relay team — **FIXED** ✅ +Verified: `dto.StudentId!.Value` dereferenced null for relay-only posts. +Fix: `RegisterStudentAsync` branches on `StudentId.HasValue`; relay-only +registrations are rejected with a clear `InvalidOperationException` ("not supported +yet") since the relay feature has no management UI or eligibility rules anywhere in +the app. Covered by two new tests. + +### 4. Advancement flags never cleared before recalculation — **FIXED** ✅ +Verified. Fix: `CalculateAdvancementAsync` resets `IsAdvanced`/`AdvanceReason` on +every lane in the round before applying the top-N and fastest-loser passes. +Covered by a new test (`HeatAdvancementResetTests`). + +### 5. "Send Advancers" duplicated heats when re-run — **FIXED** ✅ +Verified. Fix: `PopulateNextRoundAsync` now clears the next round's existing heats +and lanes first (mirroring `SeedHeatsAsync`), and errors clearly when no athletes +are marked as advancing. + +### 6. `SeedingMethod.ByPerformance` / `.Manual` silently ignored — **FIXED** ✅ +Verified: only `Random` was implemented; the UI only ever posted `Random`. +Fix: the unimplemented members were removed from the enum (nothing persists or +references them), with a comment explaining what a future implementation needs. + +### 7. `lanesPerHeat = 0` wiped a round's heats — **FIXED** ✅ +Verified (crafted-POST-only reachability also confirmed). +Fix: `SeedHeatsAsync` validates `lanesPerHeat` (1–10) **before** any deletion and +throws; the controller surfaces the message via the notification banner. + +### 8. High jump attempts 2 and 3 unreachable — **FIXED** ✅ +Verified: the read-only branch rendered as soon as any attempt row existed. +Fix: `HighJump/Index.cshtml` now always renders one dropdown per attempt slot, +pre-selected from `Attempt1/2/3` — attempts 2/3 are recordable and mistakes are +correctable. The controller also rejects a blank result (which would otherwise +bind to the enum default and record a clearance), and the OUT badge still shows. + +### 9. "Remove height" bounced to the event-level picker — **FIXED** ✅ +Verified. Fix: the form posts the missing `tournamentEventLevelId` hidden input. + +### 10. High jump results left stale scores behind — **FIXED** ✅ +Verified. Fix: `CalculateResultsAsync` deletes `Score` rows for registrations in +the event level that are absent from the ranked results, mirroring the track path. + +### 11. Field-event placements meaningless without scoring constants — **FIXED** ✅ +Verified: 20 of 32 seeded events have constants; ranking was on `CalculatedPoints`. +Fixes: +- `CalculatePlacementsAsync` ranks on `RawPerformance` (ascending for Track, + descending for Field), so placements are correct even without a constant. +- Ties share a placement (competition ranking 1, 2, 2, 4) and split the pooled + placement points — same convention as the high-jump path. New tests cover + direction, ties and pooled points. +- `CalculateFinalScoresAsync` now **throws a clear error** instead of silently + returning when the event has no `ScoringConstant`; the Field Event page shows it. +- Scores with no valid mark get their stale placement cleared (see also #27). +- The missing constants themselves can now be added in-app (#36). + +### 12. Deleting an in-use entity returned a 500 — **FIXED** ✅ +Verified for all four paths (`DeleteBehavior.Restrict` confirmed in configuration). +Fix: `StudentService.DeleteAsync`, `SchoolService.DeleteAsync`, +`EventService.DeleteAsync` and `RegistrationService.UnregisterAsync` check for +dependants first and throw `InvalidOperationException` with an actionable message +(which the controllers already surface). A new repository helper +(`IHighJumpHeightRepository.HasAttemptsForRegistrationAsync`) covers the +high-jump-attempt dependency. + +### 13. Second student without an "existing student ID" crashed — **FIXED** ✅ +Verified: `""` collided on the unfiltered unique index. +Fix: `Student.ExistingStudentId` is now nullable; the unique index is filtered to +non-null (`SchemaIntegrityFixes` migration, which also normalises existing `""` +rows to NULL); the service trims/normalises blank input to null and pre-checks +duplicates so a repeat ID gets a friendly message instead of a 500. + +### 14. Eligibility ignored active flags and tournament status — **FIXED** ✅ +Verified. Fix: `CheckEligibilityAsync` now rejects deactivated students, +deactivated schools, and completed or archived tournaments, each with a clear +reason. Covered by four new tests. + +--- + +## P2 — Security & silent failures + +### 15. Live production credentials committed — **FIXED in HEAD** ⚠️ rotation required +Verified: both appsettings files tracked, with production Postgres, dev Postgres +and a Gmail app password. +Fix: all secrets removed from `appsettings.json` / `appsettings.Production.json`; +the dead `EmailSettings` block deleted (grep confirmed no code reads it); +configuration now flows from the environment (`ConnectionStrings__DefaultConnection`) +supplied by docker-compose; `.env` added to `.gitignore`. +**Rotation and history purge are manual actions — see the top of this document.** + +### 16. Hardcoded seeded admin account — **FIXED** ✅ +Verified. Fix: the seeder reads `SeedAdmin:Email` / `SeedAdmin:Password` from +configuration; if no password is configured and no admin exists it logs a warning +and skips (nothing is created with a known password). Forced password change on +first login is not natively supported by ASP.NET Identity — the seeder logs a +reminder instead; change the password after first sign-in. + +### 17. Brute-force protection disabled + returnUrl 500 — **FIXED** ✅ +Verified. Fix: `lockoutOnFailure: true` (Identity default: 5 attempts/5 min), with +a distinct "temporarily locked" message; `LocalRedirect` replaced with +`Url.IsLocalUrl(...) ? Redirect(...) : RedirectToAction("Index", "Home")`. + +### 18. Roles seeded but never enforced — **FIXED** ✅ +Verified: only 2 of 13 authorized controllers restricted by role. +Fix (role matrix now enforced): +- `TrackEvent`, `FieldEvent`, `HighJump`: `Admin,Official` (controller-level). +- `Student`, `School`, `Event`, `Tournament`: viewing for any signed-in user; + every Create/Edit/Delete/status/archive/event-level action requires `Admin,Official`. +- `Registration`: viewing for any signed-in user; Register/Unregister for + `Admin,Official,Coach,Principal` — and coaches/principals are **scoped to their + own school** via `ApplicationUser.SchoolId` (previously never read outside user + management). +- `UserManagement`, `ScoringConfig`: `Admin` (as before). + +### 19. Heat times writable across events (IDOR) — **FIXED** ✅ +Verified. Fix: `SaveHeatTimesAsync` loads the heat's own lanes and ignores any +posted `HeatLaneId` outside that set. The same pattern in +`HighJumpService.RecordAttemptAsync` is fixed too (the registration must belong to +the bar's event level). `RemoveEventLevelAsync`/`ToggleAgeWaiverAsync` redirect +mismatches remain cosmetic (the actions themselves operate on the posted id and +are now role-restricted). + +### 20. HTTPS redirect behind Caddy without forwarded headers — **FIXED** ✅ +Verified. Fix: `UseForwardedHeaders` (`X-Forwarded-Proto|For`, known-proxy lists +cleared because the proxy's Docker-network address isn't fixed) registered before +`UseHttpsRedirection`. `Request.IsHttps`, HSTS and secure-cookie behaviour are now +correct behind the proxy. + +### 21. AutoMapper NU1903 advisory — **PARTIALLY FIXED** ⚠️ licensing decision +Verified (GHSA-rvv3-g6hj-g44x, uncontrolled-recursion DoS). Correction to the +original finding: there is **no patched 13.x**; the fix ships in 15.1.1/16.1.1, +which fall under AutoMapper's new commercial/RPL dual licence. +Done: upgraded 13.0.1 → **14.0.0** (last original-licence version). The warning is +left visible on purpose. Residual risk is negligible here (only trusted, bounded +EF entity graphs are mapped). Full remediation = the licensing decision above. +Related hygiene, all fixed: `EXPOSE 80` moved to the Dockerfile runtime stage; +`${IMAGE_TAG}` defaults to `latest` so plain `docker compose up` works; the unused +`./data` volume removed. `AllowedHosts: "*"` was left as-is deliberately — the +public hostname isn't recorded in the repo and Caddy fronts the app; set it to the +real domain if desired. + +--- + +## P3 — Performance & robustness + +### 22. N+1 query storm in high-jump results — **FIXED** ✅ +Verified. Fix: `CalculateResultsAsync` and `IsEliminatedAsync` use the attempts +already eager-loaded by `GetByTournamentEventLevelAsync`; the per-registration × +per-height re-fetch is gone (240 queries → 1 for the doc's 20×12 example). + +### 23. Per-event-level query loops in reports/standings/dashboard — **FIXED** ✅ +Verified. Fix: new `IEventRegistrationRepository.GetByTournamentAsync` loads a +tournament's registrations (student, school, zone, event, level, score) in one +query; `GetSchoolStandingsAsync`, all six `ReportService` methods and +`DashboardService` now aggregate in memory. New +`ITournamentEventLevelRepository.GetByCategoryAsync` collapses the scoring-page +picker's per-tournament loop to one query. + +### 24. Pagination loaded the entire table — **FIXED (students)** ✅ +Verified. Fix: `IStudentRepository.GetPagedAsync` pushes filtering, search, +ordering, `Skip/Take` and the true total count into SQL; `StudentController.Index` +uses it (stale page numbers are clamped and re-queried). The silent `SearchAsync` +`.Take(50)` is gone — search results are properly paged with a correct total. +Schools (~70 rows) and tournaments (a handful) keep in-memory paging deliberately: +bounded data, no measurable benefit. + +### 25. Read-then-write race on high-jump SortOrder — **FIXED** ✅ +Verified. Fix: ordering everywhere (repository, service, view) now derives from +`Height` itself, and #38's unique `(TournamentEventLevelId, Height)` index makes +the countback's "highest bar" deterministic regardless of insertion races. + +### 26. Lane assignment ignored standard seeding — **FIXED** ✅ +Verified. Fix: lanes are assigned centre-out (4, 5, 3, 6, 2, 7, 1, 8 for 8 lanes); +`PopulateNextRoundAsync` feeds athletes fastest-first, so the fastest qualifiers +get the middle lanes. + +### 27. Smaller items — **ALL FIXED** ✅ +| Item | Resolution | +|---|---| +| Recorded score didn't recalculate points | `RecordScoreAsync` recomputes `CalculatedPoints` from the event's constant, and refreshes placements if they had already been calculated for the event | +| `RawPerformance == 0` silently skipped | Points path no longer skips zero; placements explicitly exclude no-mark scores **and clear their stale placement** (tested) | +| Empty events counted as "in progress" | Dashboard counts only event levels with registrations | +| `IsAgeBased` with no `MaxAge` | "Open Boys/Girls" seeded with `IsAgeBased = false` (existing rows unaffected — seeder skips populated tables) | +| Dead `IsEliminated` branch | Removed (`FailCount` covers it) | +| `Repository.Update` on tracked entities | `Update` now attaches only detached entities; tracked ones rely on change detection | +| Deactivation check after sign-in | `IsActive` checked **before** `PasswordSignInAsync`, same generic error (no account enumeration) | +| CDN dependency for icons | Bootstrap Icons 1.11.3 vendored to `wwwroot/lib/bootstrap-icons/`; both layouts use the local copy — icons now work offline | +| Unused volume mount | Removed from docker-compose | + +--- + +## Second sweep findings + +### 28. `/Student/Details/{id}` missing view — **FIXED** ✅ +Verified (only missing view in the app). Fix: `Views/Student/Details.cshtml` added +in the style of the sibling Details pages, and a Details link added to the student +list row actions. + +### 29. Eight ViewBag key mismatches — **FIXED** ✅ +All eight verified. Fixes: `Student/Index` reads `SelectedSchoolId`/`SearchTerm`; +`School/Index` reads `SelectedZoneId`; `FieldEventController`, +`HighJumpController` and both `RegistrationController` actions now set +`EventName`/`LevelName` the way `TrackEventController` does (officials can see +which event and age group they are scoring); `ByStudent.cshtml` reads the +`ViewBag.Student` DTO. + +### 30. School level filter wrong cast — **FIXED** ✅ +Verified. Fix: `ViewBag.SelectedLevel as SchoolLevel?` with enum comparisons. + +### 31. Every notification rendered twice — **FIXED** ✅ +Verified (exactly the 13 listed views). Fix: all per-view +`` removed; the layout's single render remains. + +### 32. "Keep this alert visible" defeated — **FIXED** ✅ +Verified. Fix: the duplicate timer in `site.js` is gone. The remaining timer in +`_Notification.cshtml` was also tightened: it now dismisses only alerts the +partial itself marks `data-autodismiss` (success/error) — warnings (the jump-off +message) and informational page content ("No heats yet", "no event levels") are +never auto-dismissed. + +### 33. Eligibility pre-check computed then thrown away — **FIXED** ✅ +Verified. Fix: `Register.cshtml` got `@model EventRegistrationCreateDto`, +re-requests the page with `studentId` on selection change, renders the +eligibility verdict (green/red panel, register button disabled when ineligible), +and keeps the selection on a rejected submission. + +### 34. Events couldn't be edited or deleted from the UI — **FIXED** ✅ +Verified. Fix: Edit and Delete (with confirm) buttons per event row; inactive +events are badged. + +### 35. Event category filter unreachable — **FIXED** ✅ +Verified. Fix: a category dropdown drives `?category=`; grouping comes from +`Enum.GetValues()` so future categories appear automatically. + +### 36. Missing scoring constants couldn't be added in-app — **FIXED** ✅ +Verified. Fix: `ScoringConfig` now supports **add** (dropdown offers only events +without a constant — i.e. the 12 broken ones) and **delete** for scoring +constants, and add/delete for placement points (9th place etc.). All validation +failures surface via the notification banner instead of a silent redirect. + +### 37. Archiving hid a tournament's reports — **FIXED** ✅ +Verified. Fix: the report picker includes archived tournaments, labelled +"(archived)". + +### 38. No uniqueness on round order / heat number / bar height — **FIXED** ✅ +Verified. Fix: unique indexes on `(TournamentEventLevelId, RoundOrder)`, +`(RoundId, HeatNumber)` and `(TournamentEventLevelId, Height)` in the +`SchemaIntegrityFixes` migration. Friendly pre-checks added where users can hit +them (duplicate round order, duplicate bar height) so the constraint is a backstop, +not the error message. + +### 39. A round couldn't be re-seeded — **FIXED** ✅ +Verified. Fix: rounds with heats show a "Re-seed" button behind an explicit +confirm describing what will be replaced (handles late scratches/entries). + +### 40. Admin lock-out — **FIXED** ✅ +Verified. Fix: `ToggleActive` and `Edit` refuse to deactivate/demote the current +user and refuse any change that would leave zero active Admins. + +### 41. `test-data.sql` targeted tournaments the seeder never creates — **FIXED** ✅ +Verified (the seeder seeds no tournaments at all). Fix: the script now creates the +two named tournaments idempotently and resolves their IDs by name everywhere +(temp table `_tt`); the header comment describes reality; the summary query no +longer assumes IDs 1/2. + +### 42. Extra DB work per page / user list — **FIXED** ✅ +Verified. Fixes: a claims principal factory stamps first/last name into the +sign-in cookie and `_LoginPartial` reads claims — no more per-request +`GetUserAsync` (existing sessions fall back to the email initial until next +sign-in). `UserManagement.Index` does one query per role instead of one per user. + +### 43. Excel exports wrote `"-"` into numeric columns — **FIXED** ✅ +Verified. Fix: missing values are left blank; columns keep a uniform numeric type. + +### 44. Orphaned and mislabelled files — **FIXED** ✅ +Verified. Fixes: `userguide.html` deleted (duplicate of the live Help page); +`ViewModels/Placeholder.cs` deleted with its `_ViewImports` using; +`ScoreSheetDocument.cs` renamed to `QuestPdfLicenseInitializer.cs` to match its +content. + +### 45. Test suite covered only pure functions — **IMPROVED** ✅ / structural note +Verified. Done: the wrong leap-year comment fixed, and 11 new service-level tests +added with mocked `IUnitOfWork` covering the highest-risk fixed logic — +eligibility (inactive student/school, completed/archived tournament, relay +rejection), placement ranking direction, tie pooling, stale-placement clearing, +and advancement-flag reset. The structural recommendation stands: an integration +test suite against a real Postgres (Testcontainers) would have caught #12, #13, +#28 and #41; it needs Docker in CI and is left as follow-up work. + +--- + +## Summary of the fix pass + +- **45/45 findings verified accurate** (one detail corrected in #21). +- **43 fully fixed in code**; #15 fixed in HEAD with mandatory manual rotation; + #21 mitigated pending a licensing decision. +- New EF migration: `20260811121912_SchemaIntegrityFixes` (nullable + filtered + unique `ExistingStudentId` with data normalisation; three new unique indexes). + It applies automatically at startup (`Database.MigrateAsync`). +- Build clean (only the deliberate NU1903 remains); tests 63/63. +- Behavioural notes: scoring pages now require the `Official` (or `Admin`) role; + coaches/principals can register/unregister only their own school's students; + deploys need `DB_CONNECTION_STRING` in the environment. diff --git a/docker-compose.yml b/docker-compose.yml index 4c308d6..aa7987d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,13 +3,16 @@ services: build: context: . dockerfile: Dockerfile - image: registry.dwarrington.com/sportsdivision:${IMAGE_TAG} + image: registry.dwarrington.com/sportsdivision:${IMAGE_TAG:-latest} container_name: sportsdivision restart: always ports: - "5039:80" - volumes: - - ./data:/app/data + environment: + # Secrets are supplied via the environment (e.g. an untracked .env file), + # never committed to the repository. + ConnectionStrings__DefaultConnection: ${DB_CONNECTION_STRING:?set DB_CONNECTION_STRING in .env} + SeedAdmin__Password: ${SEED_ADMIN_PASSWORD:-} networks: - caddy_network diff --git a/src/SportsDivision.Application/DTOs/PlacementPointConfigDto.cs b/src/SportsDivision.Application/DTOs/PlacementPointConfigDto.cs index 705720c..bebc306 100644 --- a/src/SportsDivision.Application/DTOs/PlacementPointConfigDto.cs +++ b/src/SportsDivision.Application/DTOs/PlacementPointConfigDto.cs @@ -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; } } diff --git a/src/SportsDivision.Application/DTOs/ScoringConstantDto.cs b/src/SportsDivision.Application/DTOs/ScoringConstantDto.cs index 7376c8f..a7f85a6 100644 --- a/src/SportsDivision.Application/DTOs/ScoringConstantDto.cs +++ b/src/SportsDivision.Application/DTOs/ScoringConstantDto.cs @@ -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; } diff --git a/src/SportsDivision.Application/DTOs/StudentDto.cs b/src/SportsDivision.Application/DTOs/StudentDto.cs index f495dc8..6037d1d 100644 --- a/src/SportsDivision.Application/DTOs/StudentDto.cs +++ b/src/SportsDivision.Application/DTOs/StudentDto.cs @@ -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; } } diff --git a/src/SportsDivision.Application/DependencyInjection.cs b/src/SportsDivision.Application/DependencyInjection.cs index 26c47d4..02379ca 100644 --- a/src/SportsDivision.Application/DependencyInjection.cs +++ b/src/SportsDivision.Application/DependencyInjection.cs @@ -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(); services.AddScoped(); diff --git a/src/SportsDivision.Application/Interfaces/IRegistrationService.cs b/src/SportsDivision.Application/Interfaces/IRegistrationService.cs index f74965d..7e9723f 100644 --- a/src/SportsDivision.Application/Interfaces/IRegistrationService.cs +++ b/src/SportsDivision.Application/Interfaces/IRegistrationService.cs @@ -1,3 +1,3 @@ using SportsDivision.Application.DTOs; namespace SportsDivision.Application.Interfaces; -public interface IRegistrationService { Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId); Task> GetByStudentAsync(int studentId); Task RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy); Task UnregisterAsync(int eventRegistrationId); Task<(bool IsEligible, string? Reason)> CheckEligibilityAsync(int tournamentEventLevelId, int studentId); } +public interface IRegistrationService { Task GetByIdAsync(int eventRegistrationId); Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId); Task> GetByStudentAsync(int studentId); Task RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy); Task UnregisterAsync(int eventRegistrationId); Task<(bool IsEligible, string? Reason)> CheckEligibilityAsync(int tournamentEventLevelId, int studentId); } diff --git a/src/SportsDivision.Application/Interfaces/IScoringService.cs b/src/SportsDivision.Application/Interfaces/IScoringService.cs index 3224c0a..86871a9 100644 --- a/src/SportsDivision.Application/Interfaces/IScoringService.cs +++ b/src/SportsDivision.Application/Interfaces/IScoringService.cs @@ -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 RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task> GetSchoolStandingsAsync(int tournamentId); Task> GetScoringConstantsAsync(); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task> GetPlacementPointConfigsAsync(); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); } +public interface IScoringService { int CalculatePoints(decimal rawPerformance, decimal a, decimal b, decimal c, bool isTrack); Task RecordScoreAsync(ScoreCreateDto dto, string recordedBy); Task CalculateFinalScoresAsync(int tournamentEventLevelId, string recordedBy); Task CalculatePlacementsAsync(int tournamentEventLevelId); Task CalculateTrackFinalResultsAsync(int tournamentEventLevelId, string recordedBy); Task> GetSchoolStandingsAsync(int tournamentId); Task> GetScoringConstantsAsync(); Task CreateScoringConstantAsync(ScoringConstantCreateDto dto); Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto); Task DeleteScoringConstantAsync(int scoringConstantId); Task> GetPlacementPointConfigsAsync(); Task CreatePlacementPointConfigAsync(PlacementPointConfigCreateDto dto); Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto); Task DeletePlacementPointConfigAsync(int placementPointConfigId); } diff --git a/src/SportsDivision.Application/Interfaces/IStudentService.cs b/src/SportsDivision.Application/Interfaces/IStudentService.cs index 8540e2b..f637861 100644 --- a/src/SportsDivision.Application/Interfaces/IStudentService.cs +++ b/src/SportsDivision.Application/Interfaces/IStudentService.cs @@ -1,3 +1,3 @@ using SportsDivision.Application.DTOs; namespace SportsDivision.Application.Interfaces; -public interface IStudentService { Task> GetAllAsync(); Task GetByIdAsync(int id); Task GetByExistingIdAsync(string existingStudentId); Task> GetBySchoolAsync(int schoolId); Task> SearchAsync(string searchTerm); Task CreateAsync(StudentCreateDto dto); Task UpdateAsync(StudentUpdateDto dto); Task DeleteAsync(int id); } +public interface IStudentService { Task> GetAllAsync(); Task GetByIdAsync(int id); Task GetByExistingIdAsync(string existingStudentId); Task> GetBySchoolAsync(int schoolId); Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize); Task CreateAsync(StudentCreateDto dto); Task UpdateAsync(StudentUpdateDto dto); Task DeleteAsync(int id); } diff --git a/src/SportsDivision.Application/Services/DashboardService.cs b/src/SportsDivision.Application/Services/DashboardService.cs index abda842..00e1522 100644 --- a/src/SportsDivision.Application/Services/DashboardService.cs +++ b/src/SportsDivision.Application/Services/DashboardService.cs @@ -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(); - 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); diff --git a/src/SportsDivision.Application/Services/EventService.cs b/src/SportsDivision.Application/Services/EventService.cs index d43ae65..6473b40 100644 --- a/src/SportsDivision.Application/Services/EventService.cs +++ b/src/SportsDivision.Application/Services/EventService.cs @@ -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(); } diff --git a/src/SportsDivision.Application/Services/HeatManagementService.cs b/src/SportsDivision.Application/Services/HeatManagementService.cs index 852ac73..d559b4e 100644 --- a/src/SportsDivision.Application/Services/HeatManagementService.cs +++ b/src/SportsDivision.Application/Services/HeatManagementService.cs @@ -20,6 +20,10 @@ public class HeatManagementService : IHeatManagementService public async Task 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(dto); await _uow.Rounds.AddAsync(round); await _uow.SaveChangesAsync(); @@ -38,8 +42,22 @@ public class HeatManagementService : IHeatManagementService return round == null ? null : _mapper.Map(round); } + /// + /// Lane numbers in preference order for a heat of 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. + /// + 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 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 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) diff --git a/src/SportsDivision.Application/Services/HighJumpService.cs b/src/SportsDivision.Application/Services/HighJumpService.cs index 087f719..03f4b13 100644 --- a/src/SportsDivision.Application/Services/HighJumpService.cs +++ b/src/SportsDivision.Application/Services/HighJumpService.cs @@ -26,7 +26,12 @@ public class HighJumpService : IHighJumpService public async Task 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 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 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 diff --git a/src/SportsDivision.Application/Services/RegistrationService.cs b/src/SportsDivision.Application/Services/RegistrationService.cs index d9f70bd..82549e1 100644 --- a/src/SportsDivision.Application/Services/RegistrationService.cs +++ b/src/SportsDivision.Application/Services/RegistrationService.cs @@ -17,6 +17,12 @@ public class RegistrationService : IRegistrationService _mapper = mapper; } + public async Task GetByIdAsync(int eventRegistrationId) + { + var reg = await _uow.EventRegistrations.GetByIdAsync(eventRegistrationId); + return reg == null ? null : _mapper.Map(reg); + } + public async Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId) { var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tournamentEventLevelId); @@ -31,7 +37,17 @@ public class RegistrationService : IRegistrationService public async Task 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 diff --git a/src/SportsDivision.Application/Services/ReportService.cs b/src/SportsDivision.Application/Services/ReportService.cs index 7c0d50e..d5bacce 100644 --- a/src/SportsDivision.Application/Services/ReportService.cs +++ b/src/SportsDivision.Application/Services/ReportService.cs @@ -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> GetPopularEventsAsync(int tournamentId) { - var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId); + var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId); var report = new Dictionary(); - 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> GetRegistrationByGenderAsync(int tournamentId, int? zoneId = null) { - var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId); + var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId); var report = new List(); - 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> 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(); - 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> 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(); - 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> 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(); - 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> 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(); - 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); diff --git a/src/SportsDivision.Application/Services/SchoolService.cs b/src/SportsDivision.Application/Services/SchoolService.cs index ef58276..c909fd1 100644 --- a/src/SportsDivision.Application/Services/SchoolService.cs +++ b/src/SportsDivision.Application/Services/SchoolService.cs @@ -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(); } diff --git a/src/SportsDivision.Application/Services/ScoringService.cs b/src/SportsDivision.Application/Services/ScoringService.cs index cdbddd7..bff3830 100644 --- a/src/SportsDivision.Application/Services/ScoringService.cs +++ b/src/SportsDivision.Application/Services/ScoringService.cs @@ -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(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(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> 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(); - 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>(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>(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) diff --git a/src/SportsDivision.Application/Services/StudentService.cs b/src/SportsDivision.Application/Services/StudentService.cs index a5918f4..b8c97de 100644 --- a/src/SportsDivision.Application/Services/StudentService.cs +++ b/src/SportsDivision.Application/Services/StudentService.cs @@ -41,14 +41,15 @@ public class StudentService : IStudentService return _mapper.Map>(students); } - public async Task> SearchAsync(string searchTerm) + public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize) { - var students = await _uow.Students.SearchAsync(searchTerm); - return _mapper.Map>(students); + var (students, total) = await _uow.Students.GetPagedAsync(schoolId, searchTerm, page, pageSize); + return (_mapper.Map>(students), total); } public async Task CreateAsync(StudentCreateDto dto) { + await NormalizeAndCheckExistingIdAsync(dto, studentId: null); var student = _mapper.Map(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(); } diff --git a/src/SportsDivision.Application/Services/TournamentService.cs b/src/SportsDivision.Application/Services/TournamentService.cs index 58cb476..bb6bef6 100644 --- a/src/SportsDivision.Application/Services/TournamentService.cs +++ b/src/SportsDivision.Application/Services/TournamentService.cs @@ -106,19 +106,8 @@ public class TournamentService : ITournamentService public async Task> GetEventLevelsByCategoryAsync(EventCategory category) { - var tournaments = await _uow.Tournaments.GetActiveAsync(); - var result = new List(); - 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(tel); - dto.TournamentName = t.Name; - result.Add(dto); - } - } - return result; + var tels = await _uow.TournamentEventLevels.GetByCategoryAsync(category); + return _mapper.Map>(tels); } public async Task AddEventLevelAsync(TournamentEventLevelCreateDto dto) diff --git a/src/SportsDivision.Application/SportsDivision.Application.csproj b/src/SportsDivision.Application/SportsDivision.Application.csproj index f9eb0b9..ca90541 100644 --- a/src/SportsDivision.Application/SportsDivision.Application.csproj +++ b/src/SportsDivision.Application/SportsDivision.Application.csproj @@ -10,8 +10,11 @@ - + + + diff --git a/src/SportsDivision.Domain/Entities/Student.cs b/src/SportsDivision.Domain/Entities/Student.cs index b542359..5b83fb8 100644 --- a/src/SportsDivision.Domain/Entities/Student.cs +++ b/src/SportsDivision.Domain/Entities/Student.cs @@ -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; } diff --git a/src/SportsDivision.Domain/Enums/SeedingMethod.cs b/src/SportsDivision.Domain/Enums/SeedingMethod.cs index ccbd352..e214563 100644 --- a/src/SportsDivision.Domain/Enums/SeedingMethod.cs +++ b/src/SportsDivision.Domain/Enums/SeedingMethod.cs @@ -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 } diff --git a/src/SportsDivision.Domain/Interfaces/IEventRegistrationRepository.cs b/src/SportsDivision.Domain/Interfaces/IEventRegistrationRepository.cs index 5d3e5be..5c562a1 100644 --- a/src/SportsDivision.Domain/Interfaces/IEventRegistrationRepository.cs +++ b/src/SportsDivision.Domain/Interfaces/IEventRegistrationRepository.cs @@ -5,6 +5,10 @@ namespace SportsDivision.Domain.Interfaces; public interface IEventRegistrationRepository : IRepository { Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId); + /// 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. + Task> GetByTournamentAsync(int tournamentId); Task> GetByStudentAsync(int studentId); Task IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId); Task> GetBySchoolAndTournamentAsync(int schoolId, int tournamentId); diff --git a/src/SportsDivision.Domain/Interfaces/IHighJumpHeightRepository.cs b/src/SportsDivision.Domain/Interfaces/IHighJumpHeightRepository.cs index d652640..7970d18 100644 --- a/src/SportsDivision.Domain/Interfaces/IHighJumpHeightRepository.cs +++ b/src/SportsDivision.Domain/Interfaces/IHighJumpHeightRepository.cs @@ -4,6 +4,7 @@ namespace SportsDivision.Domain.Interfaces; public interface IHighJumpHeightRepository : IRepository { + Task HasAttemptsForRegistrationAsync(int eventRegistrationId); Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId); Task GetWithAttemptsAsync(int heightId); } diff --git a/src/SportsDivision.Domain/Interfaces/IStudentRepository.cs b/src/SportsDivision.Domain/Interfaces/IStudentRepository.cs index 5577e08..b9b6198 100644 --- a/src/SportsDivision.Domain/Interfaces/IStudentRepository.cs +++ b/src/SportsDivision.Domain/Interfaces/IStudentRepository.cs @@ -7,5 +7,9 @@ public interface IStudentRepository : IRepository Task GetByExistingIdAsync(string existingStudentId); Task> GetBySchoolAsync(int schoolId); Task GetWithRegistrationsAsync(int studentId); - Task> SearchAsync(string searchTerm); + /// + /// Server-side filtered, ordered and paged student query. A + /// of 0 (or less) returns all matching rows. Returns the page plus the total match count. + /// + Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize); } diff --git a/src/SportsDivision.Domain/Interfaces/ITournamentEventLevelRepository.cs b/src/SportsDivision.Domain/Interfaces/ITournamentEventLevelRepository.cs index e0e7a10..b772e9b 100644 --- a/src/SportsDivision.Domain/Interfaces/ITournamentEventLevelRepository.cs +++ b/src/SportsDivision.Domain/Interfaces/ITournamentEventLevelRepository.cs @@ -1,9 +1,12 @@ using SportsDivision.Domain.Entities; +using SportsDivision.Domain.Enums; namespace SportsDivision.Domain.Interfaces; public interface ITournamentEventLevelRepository : IRepository { + /// Event levels of the given category across all non-archived tournaments, in one query. + Task> GetByCategoryAsync(EventCategory category); Task GetWithRegistrationsAsync(int id); Task GetWithRoundsAsync(int id); Task> GetByTournamentAsync(int tournamentId); diff --git a/src/SportsDivision.Infrastructure/Data/Configurations/HeatConfiguration.cs b/src/SportsDivision.Infrastructure/Data/Configurations/HeatConfiguration.cs index ca780d1..9f2adac 100644 --- a/src/SportsDivision.Infrastructure/Data/Configurations/HeatConfiguration.cs +++ b/src/SportsDivision.Infrastructure/Data/Configurations/HeatConfiguration.cs @@ -11,5 +11,6 @@ public class HeatConfiguration : IEntityTypeConfiguration builder.HasKey(h => h.HeatId); builder.Property(h => h.Status).HasConversion().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(); } } diff --git a/src/SportsDivision.Infrastructure/Data/Configurations/HighJumpHeightConfiguration.cs b/src/SportsDivision.Infrastructure/Data/Configurations/HighJumpHeightConfiguration.cs index f10c52c..948aa60 100644 --- a/src/SportsDivision.Infrastructure/Data/Configurations/HighJumpHeightConfiguration.cs +++ b/src/SportsDivision.Infrastructure/Data/Configurations/HighJumpHeightConfiguration.cs @@ -11,5 +11,8 @@ public class HighJumpHeightConfiguration : IEntityTypeConfiguration 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(); } } diff --git a/src/SportsDivision.Infrastructure/Data/Configurations/RoundConfiguration.cs b/src/SportsDivision.Infrastructure/Data/Configurations/RoundConfiguration.cs index 7f4aff9..34d27f8 100644 --- a/src/SportsDivision.Infrastructure/Data/Configurations/RoundConfiguration.cs +++ b/src/SportsDivision.Infrastructure/Data/Configurations/RoundConfiguration.cs @@ -12,5 +12,8 @@ public class RoundConfiguration : IEntityTypeConfiguration builder.Property(r => r.RoundType).HasConversion().HasMaxLength(20); builder.Property(r => r.Status).HasConversion().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(); } } diff --git a/src/SportsDivision.Infrastructure/Data/Configurations/StudentConfiguration.cs b/src/SportsDivision.Infrastructure/Data/Configurations/StudentConfiguration.cs index b117751..7121c2a 100644 --- a/src/SportsDivision.Infrastructure/Data/Configurations/StudentConfiguration.cs +++ b/src/SportsDivision.Infrastructure/Data/Configurations/StudentConfiguration.cs @@ -9,8 +9,10 @@ public class StudentConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder 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().HasMaxLength(10); diff --git a/src/SportsDivision.Infrastructure/DependencyInjection.cs b/src/SportsDivision.Infrastructure/DependencyInjection.cs index 650b9a5..c70e45a 100644 --- a/src/SportsDivision.Infrastructure/DependencyInjection.cs +++ b/src/SportsDivision.Infrastructure/DependencyInjection.cs @@ -27,6 +27,7 @@ public static class DependencyInjection options.User.RequireUniqueEmail = true; }) .AddEntityFrameworkStores() + .AddClaimsPrincipalFactory() .AddDefaultTokenProviders(); services.ConfigureApplicationCookie(options => diff --git a/src/SportsDivision.Infrastructure/Identity/AppClaimsPrincipalFactory.cs b/src/SportsDivision.Infrastructure/Identity/AppClaimsPrincipalFactory.cs new file mode 100644 index 0000000..58e7777 --- /dev/null +++ b/src/SportsDivision.Infrastructure/Identity/AppClaimsPrincipalFactory.cs @@ -0,0 +1,28 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; + +namespace SportsDivision.Infrastructure.Identity; + +/// +/// 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. +/// +public class AppClaimsPrincipalFactory : UserClaimsPrincipalFactory +{ + public AppClaimsPrincipalFactory( + UserManager userManager, + RoleManager roleManager, + IOptions options) + : base(userManager, roleManager, options) { } + + protected override async Task 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; + } +} diff --git a/src/SportsDivision.Infrastructure/Migrations/20260811121912_SchemaIntegrityFixes.Designer.cs b/src/SportsDivision.Infrastructure/Migrations/20260811121912_SchemaIntegrityFixes.Designer.cs new file mode 100644 index 0000000..b7bf8b8 --- /dev/null +++ b/src/SportsDivision.Infrastructure/Migrations/20260811121912_SchemaIntegrityFixes.Designer.cs @@ -0,0 +1,1231 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SportsDivision.Infrastructure.Data; + +#nullable disable + +namespace SportsDivision.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260811121912_SchemaIntegrityFixes")] + partial class SchemaIntegrityFixes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EventEventLevel", b => + { + b.Property("EventLevelsEventLevelId") + .HasColumnType("integer"); + + b.Property("EventsEventId") + .HasColumnType("integer"); + + b.HasKey("EventLevelsEventLevelId", "EventsEventId"); + + b.HasIndex("EventsEventId"); + + b.ToTable("EventEventLevel", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Event", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EventId")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsRelay") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PrimarySchool") + .HasColumnType("boolean"); + + b.Property("SecondarySchool") + .HasColumnType("boolean"); + + b.HasKey("EventId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.EventLevel", b => + { + b.Property("EventLevelId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EventLevelId")); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsAgeBased") + .HasColumnType("boolean"); + + b.Property("MaxAge") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SchoolLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Sex") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("EventLevelId"); + + b.HasIndex("Name", "Sex") + .IsUnique(); + + b.ToTable("EventLevels"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.EventRegistration", b => + { + b.Property("EventRegistrationId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("EventRegistrationId")); + + b.Property("RegisteredAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RegisteredBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("RelayTeamId") + .HasColumnType("integer"); + + b.Property("StudentId") + .HasColumnType("integer"); + + b.Property("TournamentEventLevelId") + .HasColumnType("integer"); + + b.HasKey("EventRegistrationId"); + + b.HasIndex("RelayTeamId"); + + b.HasIndex("StudentId"); + + b.HasIndex("TournamentEventLevelId", "StudentId") + .IsUnique() + .HasFilter("\"StudentId\" IS NOT NULL"); + + b.ToTable("EventRegistrations"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Heat", b => + { + b.Property("HeatId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("HeatId")); + + b.Property("HeatNumber") + .HasColumnType("integer"); + + b.Property("RoundId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("HeatId"); + + b.HasIndex("RoundId", "HeatNumber") + .IsUnique(); + + b.ToTable("Heats"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.HeatLane", b => + { + b.Property("HeatLaneId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("HeatLaneId")); + + b.Property("AdvanceReason") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("EventRegistrationId") + .HasColumnType("integer"); + + b.Property("HeatId") + .HasColumnType("integer"); + + b.Property("IsAdvanced") + .HasColumnType("boolean"); + + b.Property("IsDNF") + .HasColumnType("boolean"); + + b.Property("IsDNS") + .HasColumnType("boolean"); + + b.Property("IsDQ") + .HasColumnType("boolean"); + + b.Property("LaneNumber") + .HasColumnType("integer"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Time") + .HasPrecision(10, 3) + .HasColumnType("numeric(10,3)"); + + b.HasKey("HeatLaneId"); + + b.HasIndex("EventRegistrationId"); + + b.HasIndex("HeatId", "LaneNumber") + .IsUnique(); + + b.ToTable("HeatLanes"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.HighJumpAttempt", b => + { + b.Property("HighJumpAttemptId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("HighJumpAttemptId")); + + b.Property("Attempt1") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Attempt2") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Attempt3") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("EventRegistrationId") + .HasColumnType("integer"); + + b.Property("HighJumpHeightId") + .HasColumnType("integer"); + + b.HasKey("HighJumpAttemptId"); + + b.HasIndex("EventRegistrationId"); + + b.HasIndex("HighJumpHeightId", "EventRegistrationId") + .IsUnique(); + + b.ToTable("HighJumpAttempts"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.HighJumpHeight", b => + { + b.Property("HighJumpHeightId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("HighJumpHeightId")); + + b.Property("Height") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TournamentEventLevelId") + .HasColumnType("integer"); + + b.HasKey("HighJumpHeightId"); + + b.HasIndex("TournamentEventLevelId", "Height") + .IsUnique(); + + b.ToTable("HighJumpHeights"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.PlacementPointConfig", b => + { + b.Property("PlacementPointConfigId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PlacementPointConfigId")); + + b.Property("Placement") + .HasColumnType("integer"); + + b.Property("Points") + .HasColumnType("integer"); + + b.HasKey("PlacementPointConfigId"); + + b.HasIndex("Placement") + .IsUnique(); + + b.ToTable("PlacementPointConfigs"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.RelayTeam", b => + { + b.Property("RelayTeamId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RelayTeamId")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SchoolId") + .HasColumnType("integer"); + + b.HasKey("RelayTeamId"); + + b.HasIndex("SchoolId"); + + b.ToTable("RelayTeams"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.RelayTeamMember", b => + { + b.Property("RelayTeamMemberId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RelayTeamMemberId")); + + b.Property("RelayTeamId") + .HasColumnType("integer"); + + b.Property("RunOrder") + .HasColumnType("integer"); + + b.Property("StudentId") + .HasColumnType("integer"); + + b.HasKey("RelayTeamMemberId"); + + b.HasIndex("StudentId"); + + b.HasIndex("RelayTeamId", "StudentId") + .IsUnique(); + + b.ToTable("RelayTeamMembers"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Round", b => + { + b.Property("RoundId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RoundId")); + + b.Property("AdvanceFastestLosers") + .HasColumnType("integer"); + + b.Property("AdvanceTopN") + .HasColumnType("integer"); + + b.Property("RoundOrder") + .HasColumnType("integer"); + + b.Property("RoundType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TournamentEventLevelId") + .HasColumnType("integer"); + + b.HasKey("RoundId"); + + b.HasIndex("TournamentEventLevelId", "RoundOrder") + .IsUnique(); + + b.ToTable("Rounds"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.School", b => + { + b.Property("SchoolId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SchoolId")); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SchoolLevel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ShortName") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ZoneId") + .HasColumnType("integer"); + + b.HasKey("SchoolId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("ZoneId"); + + b.ToTable("Schools"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Score", b => + { + b.Property("ScoreId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ScoreId")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("EventRegistrationId") + .HasColumnType("integer"); + + b.Property("Placement") + .HasColumnType("integer"); + + b.Property("PlacementPoints") + .HasColumnType("integer"); + + b.Property("RawPerformance") + .HasPrecision(10, 3) + .HasColumnType("numeric(10,3)"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecordedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("ScoreId"); + + b.HasIndex("EventRegistrationId") + .IsUnique(); + + b.ToTable("Scores"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.ScoringConstant", b => + { + b.Property("ScoringConstantId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ScoringConstantId")); + + b.Property("A") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("B") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("C") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Unit") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("ScoringConstantId"); + + b.HasIndex("EventId"); + + b.ToTable("ScoringConstants"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Student", b => + { + b.Property("StudentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("StudentId")); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("ExistingStudentId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SchoolId") + .HasColumnType("integer"); + + b.Property("Sex") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.HasKey("StudentId"); + + b.HasIndex("ExistingStudentId") + .IsUnique() + .HasFilter("\"ExistingStudentId\" IS NOT NULL"); + + b.HasIndex("SchoolId"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Tournament", b => + { + b.Property("TournamentId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TournamentId")); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SchoolLevel") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ZoneId") + .HasColumnType("integer"); + + b.HasKey("TournamentId"); + + b.HasIndex("ZoneId"); + + b.ToTable("Tournaments"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.TournamentEventLevel", b => + { + b.Property("TournamentEventLevelId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TournamentEventLevelId")); + + b.Property("AgeRestrictionWaived") + .HasColumnType("boolean"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("EventLevelId") + .HasColumnType("integer"); + + b.Property("TournamentId") + .HasColumnType("integer"); + + b.HasKey("TournamentEventLevelId"); + + b.HasIndex("EventId"); + + b.HasIndex("EventLevelId"); + + b.HasIndex("TournamentId", "EventId", "EventLevelId") + .IsUnique(); + + b.ToTable("TournamentEventLevels"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Zone", b => + { + b.Property("ZoneId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ZoneId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("ZoneId"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("Zones"); + }); + + modelBuilder.Entity("SportsDivision.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SchoolId") + .HasColumnType("integer"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("EventEventLevel", b => + { + b.HasOne("SportsDivision.Domain.Entities.EventLevel", null) + .WithMany() + .HasForeignKey("EventLevelsEventLevelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SportsDivision.Domain.Entities.Event", null) + .WithMany() + .HasForeignKey("EventsEventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SportsDivision.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SportsDivision.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SportsDivision.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SportsDivision.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.EventRegistration", b => + { + b.HasOne("SportsDivision.Domain.Entities.RelayTeam", "RelayTeam") + .WithMany("Registrations") + .HasForeignKey("RelayTeamId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SportsDivision.Domain.Entities.Student", "Student") + .WithMany("Registrations") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("SportsDivision.Domain.Entities.TournamentEventLevel", "TournamentEventLevel") + .WithMany("Registrations") + .HasForeignKey("TournamentEventLevelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RelayTeam"); + + b.Navigation("Student"); + + b.Navigation("TournamentEventLevel"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Heat", b => + { + b.HasOne("SportsDivision.Domain.Entities.Round", "Round") + .WithMany("Heats") + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.HeatLane", b => + { + b.HasOne("SportsDivision.Domain.Entities.EventRegistration", "EventRegistration") + .WithMany("HeatLanes") + .HasForeignKey("EventRegistrationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SportsDivision.Domain.Entities.Heat", "Heat") + .WithMany("HeatLanes") + .HasForeignKey("HeatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventRegistration"); + + b.Navigation("Heat"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.HighJumpAttempt", b => + { + b.HasOne("SportsDivision.Domain.Entities.EventRegistration", "EventRegistration") + .WithMany() + .HasForeignKey("EventRegistrationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SportsDivision.Domain.Entities.HighJumpHeight", "HighJumpHeight") + .WithMany("Attempts") + .HasForeignKey("HighJumpHeightId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventRegistration"); + + b.Navigation("HighJumpHeight"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.HighJumpHeight", b => + { + b.HasOne("SportsDivision.Domain.Entities.TournamentEventLevel", "TournamentEventLevel") + .WithMany("HighJumpHeights") + .HasForeignKey("TournamentEventLevelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TournamentEventLevel"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.RelayTeam", b => + { + b.HasOne("SportsDivision.Domain.Entities.School", "School") + .WithMany() + .HasForeignKey("SchoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("School"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.RelayTeamMember", b => + { + b.HasOne("SportsDivision.Domain.Entities.RelayTeam", "RelayTeam") + .WithMany("Members") + .HasForeignKey("RelayTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SportsDivision.Domain.Entities.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RelayTeam"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Round", b => + { + b.HasOne("SportsDivision.Domain.Entities.TournamentEventLevel", "TournamentEventLevel") + .WithMany("Rounds") + .HasForeignKey("TournamentEventLevelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TournamentEventLevel"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.School", b => + { + b.HasOne("SportsDivision.Domain.Entities.Zone", "Zone") + .WithMany("Schools") + .HasForeignKey("ZoneId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Zone"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Score", b => + { + b.HasOne("SportsDivision.Domain.Entities.EventRegistration", "EventRegistration") + .WithOne("Score") + .HasForeignKey("SportsDivision.Domain.Entities.Score", "EventRegistrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventRegistration"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.ScoringConstant", b => + { + b.HasOne("SportsDivision.Domain.Entities.Event", "Event") + .WithMany("ScoringConstants") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Student", b => + { + b.HasOne("SportsDivision.Domain.Entities.School", "School") + .WithMany("Students") + .HasForeignKey("SchoolId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("School"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Tournament", b => + { + b.HasOne("SportsDivision.Domain.Entities.Zone", "Zone") + .WithMany() + .HasForeignKey("ZoneId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Zone"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.TournamentEventLevel", b => + { + b.HasOne("SportsDivision.Domain.Entities.Event", "Event") + .WithMany("TournamentEventLevels") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SportsDivision.Domain.Entities.EventLevel", "EventLevel") + .WithMany("TournamentEventLevels") + .HasForeignKey("EventLevelId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("SportsDivision.Domain.Entities.Tournament", "Tournament") + .WithMany("TournamentEventLevels") + .HasForeignKey("TournamentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Event"); + + b.Navigation("EventLevel"); + + b.Navigation("Tournament"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Event", b => + { + b.Navigation("ScoringConstants"); + + b.Navigation("TournamentEventLevels"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.EventLevel", b => + { + b.Navigation("TournamentEventLevels"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.EventRegistration", b => + { + b.Navigation("HeatLanes"); + + b.Navigation("Score"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Heat", b => + { + b.Navigation("HeatLanes"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.HighJumpHeight", b => + { + b.Navigation("Attempts"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.RelayTeam", b => + { + b.Navigation("Members"); + + b.Navigation("Registrations"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Round", b => + { + b.Navigation("Heats"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.School", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Student", b => + { + b.Navigation("Registrations"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Tournament", b => + { + b.Navigation("TournamentEventLevels"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.TournamentEventLevel", b => + { + b.Navigation("HighJumpHeights"); + + b.Navigation("Registrations"); + + b.Navigation("Rounds"); + }); + + modelBuilder.Entity("SportsDivision.Domain.Entities.Zone", b => + { + b.Navigation("Schools"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/SportsDivision.Infrastructure/Migrations/20260811121912_SchemaIntegrityFixes.cs b/src/SportsDivision.Infrastructure/Migrations/20260811121912_SchemaIntegrityFixes.cs new file mode 100644 index 0000000..2886291 --- /dev/null +++ b/src/SportsDivision.Infrastructure/Migrations/20260811121912_SchemaIntegrityFixes.cs @@ -0,0 +1,122 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SportsDivision.Infrastructure.Migrations +{ + /// + public partial class SchemaIntegrityFixes : Migration + { + /// + 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( + 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); + } + + /// + 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( + 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"); + } + } +} diff --git a/src/SportsDivision.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/SportsDivision.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index bd9c017..c6b390c 100644 --- a/src/SportsDivision.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/SportsDivision.Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -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("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"); diff --git a/src/SportsDivision.Infrastructure/Repositories/EventRegistrationRepository.cs b/src/SportsDivision.Infrastructure/Repositories/EventRegistrationRepository.cs index 2c1eadf..fb57147 100644 --- a/src/SportsDivision.Infrastructure/Repositories/EventRegistrationRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/EventRegistrationRepository.cs @@ -9,6 +9,7 @@ public class EventRegistrationRepository : Repository, IEvent { public EventRegistrationRepository(ApplicationDbContext context) : base(context) { } public async Task> 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> 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> 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 IsStudentRegisteredAsync(int tournamentEventLevelId, int studentId) => await _dbSet.AnyAsync(r => r.TournamentEventLevelId == tournamentEventLevelId && r.StudentId == studentId); public async Task> 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(); diff --git a/src/SportsDivision.Infrastructure/Repositories/HighJumpHeightRepository.cs b/src/SportsDivision.Infrastructure/Repositories/HighJumpHeightRepository.cs index 1d726aa..c83d092 100644 --- a/src/SportsDivision.Infrastructure/Repositories/HighJumpHeightRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/HighJumpHeightRepository.cs @@ -8,6 +8,9 @@ namespace SportsDivision.Infrastructure.Repositories; public class HighJumpHeightRepository : Repository, IHighJumpHeightRepository { public HighJumpHeightRepository(ApplicationDbContext context) : base(context) { } - public async Task> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(h => h.TournamentEventLevelId == tournamentEventLevelId).Include(h => h.Attempts).OrderBy(h => h.SortOrder).ToListAsync(); + public async Task HasAttemptsForRegistrationAsync(int eventRegistrationId) => await _context.Set().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> GetByTournamentEventLevelAsync(int tournamentEventLevelId) => await _dbSet.Where(h => h.TournamentEventLevelId == tournamentEventLevelId).Include(h => h.Attempts).OrderBy(h => h.Height).ToListAsync(); public async Task 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); } diff --git a/src/SportsDivision.Infrastructure/Repositories/Repository.cs b/src/SportsDivision.Infrastructure/Repositories/Repository.cs index 43bc729..635e958 100644 --- a/src/SportsDivision.Infrastructure/Repositories/Repository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/Repository.cs @@ -21,7 +21,15 @@ public class Repository : IRepository where T : class public async Task> FindAsync(Expression> predicate) => await _dbSet.Where(predicate).ToListAsync(); public async Task AddAsync(T entity) { await _dbSet.AddAsync(entity); return entity; } public async Task AddRangeAsync(IEnumerable 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 AnyAsync(Expression> predicate) => await _dbSet.AnyAsync(predicate); public async Task CountAsync(Expression>? predicate = null) => predicate == null ? await _dbSet.CountAsync() : await _dbSet.CountAsync(predicate); diff --git a/src/SportsDivision.Infrastructure/Repositories/StudentRepository.cs b/src/SportsDivision.Infrastructure/Repositories/StudentRepository.cs index 79c0ab2..109ebf6 100644 --- a/src/SportsDivision.Infrastructure/Repositories/StudentRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/StudentRepository.cs @@ -11,5 +11,22 @@ public class StudentRepository : Repository, IStudentRepository public async Task GetByExistingIdAsync(string existingStudentId) => await _dbSet.Include(s => s.School).FirstOrDefaultAsync(s => s.ExistingStudentId == existingStudentId); public async Task> 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 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> 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 Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize) + { + IQueryable 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); + } } diff --git a/src/SportsDivision.Infrastructure/Repositories/TournamentEventLevelRepository.cs b/src/SportsDivision.Infrastructure/Repositories/TournamentEventLevelRepository.cs index d35c9a9..eed0a2e 100644 --- a/src/SportsDivision.Infrastructure/Repositories/TournamentEventLevelRepository.cs +++ b/src/SportsDivision.Infrastructure/Repositories/TournamentEventLevelRepository.cs @@ -8,6 +8,7 @@ namespace SportsDivision.Infrastructure.Repositories; public class TournamentEventLevelRepository : Repository, ITournamentEventLevelRepository { public TournamentEventLevelRepository(ApplicationDbContext context) : base(context) { } + public async Task> 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 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 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> 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(); diff --git a/src/SportsDivision.Infrastructure/Seeding/DatabaseSeeder.cs b/src/SportsDivision.Infrastructure/Seeding/DatabaseSeeder.cs index 25c2191..71ff5ad 100644 --- a/src/SportsDivision.Infrastructure/Seeding/DatabaseSeeder.cs +++ b/src/SportsDivision.Infrastructure/Seeding/DatabaseSeeder.cs @@ -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 _userManager; private readonly RoleManager _roleManager; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; - public DatabaseSeeder(ApplicationDbContext context, UserManager userManager, RoleManager roleManager) + public DatabaseSeeder( + ApplicationDbContext context, + UserManager userManager, + RoleManager roleManager, + IConfiguration configuration, + ILogger 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(); } diff --git a/src/SportsDivision.Web/Controllers/AccountController.cs b/src/SportsDivision.Web/Controllers/AccountController.cs index f8a0898..594656d 100644 --- a/src/SportsDivision.Web/Controllers/AccountController.cs +++ b/src/SportsDivision.Web/Controllers/AccountController.cs @@ -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(); diff --git a/src/SportsDivision.Web/Controllers/EventController.cs b/src/SportsDivision.Web/Controllers/EventController.cs index 2c5fbaa..b6a9c4d 100644 --- a/src/SportsDivision.Web/Controllers/EventController.cs +++ b/src/SportsDivision.Web/Controllers/EventController.cs @@ -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 Create(EventCreateDto dto) @@ -62,6 +64,7 @@ public class EventController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpGet] public async Task Edit(int id) { @@ -93,6 +96,7 @@ public class EventController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(EventUpdateDto dto) @@ -119,6 +123,7 @@ public class EventController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Delete(int id) diff --git a/src/SportsDivision.Web/Controllers/FieldEventController.cs b/src/SportsDivision.Web/Controllers/FieldEventController.cs index 5f5abd8..5d15edd 100644 --- a/src/SportsDivision.Web/Controllers/FieldEventController.cs +++ b/src/SportsDivision.Web/Controllers/FieldEventController.cs @@ -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 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 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 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 }); } } diff --git a/src/SportsDivision.Web/Controllers/HighJumpController.cs b/src/SportsDivision.Web/Controllers/HighJumpController.cs index bc677cb..d7ae752 100644 --- a/src/SportsDivision.Web/Controllers/HighJumpController.cs +++ b/src/SportsDivision.Web/Controllers/HighJumpController.cs @@ -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 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 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 }); } diff --git a/src/SportsDivision.Web/Controllers/RegistrationController.cs b/src/SportsDivision.Web/Controllers/RegistrationController.cs index 4dc0a5e..53db2f5 100644 --- a/src/SportsDivision.Web/Controllers/RegistrationController.cs +++ b/src/SportsDivision.Web/Controllers/RegistrationController.cs @@ -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 _userManager; public RegistrationController( IRegistrationService registrationService, ITournamentService tournamentService, - IStudentService studentService) + IStudentService studentService, + UserManager userManager) { _registrationService = registrationService; _tournamentService = tournamentService; _studentService = studentService; + _userManager = userManager; + } + + /// + /// Coaches and principals act only for their own school; admins and officials + /// are unrestricted. Returns an error message, or null when allowed. + /// + private async Task 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 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 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 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 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); diff --git a/src/SportsDivision.Web/Controllers/ReportController.cs b/src/SportsDivision.Web/Controllers/ReportController.cs index 1081bff..fd7bc5f 100644 --- a/src/SportsDivision.Web/Controllers/ReportController.cs +++ b/src/SportsDivision.Web/Controllers/ReportController.cs @@ -26,7 +26,9 @@ public class ReportController : Controller [HttpGet] public async Task 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); } diff --git a/src/SportsDivision.Web/Controllers/SchoolController.cs b/src/SportsDivision.Web/Controllers/SchoolController.cs index aee1b15..5052622 100644 --- a/src/SportsDivision.Web/Controllers/SchoolController.cs +++ b/src/SportsDivision.Web/Controllers/SchoolController.cs @@ -62,6 +62,7 @@ public class SchoolController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpGet] public async Task Create() { @@ -69,6 +70,7 @@ public class SchoolController : Controller return View(new SchoolCreateDto()); } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Create(SchoolCreateDto dto) @@ -93,6 +95,7 @@ public class SchoolController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpGet] public async Task Edit(int id) { @@ -123,6 +126,7 @@ public class SchoolController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(SchoolUpdateDto dto) @@ -151,6 +155,7 @@ public class SchoolController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Delete(int id) diff --git a/src/SportsDivision.Web/Controllers/ScoringConfigController.cs b/src/SportsDivision.Web/Controllers/ScoringConfigController.cs index 1dfb9a4..2a3d4ea 100644 --- a/src/SportsDivision.Web/Controllers/ScoringConfigController.cs +++ b/src/SportsDivision.Web/Controllers/ScoringConfigController.cs @@ -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 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 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 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 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 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 DeletePlacementPointConfig(int placementPointConfigId) + { + try + { + await _scoringService.DeletePlacementPointConfigAsync(placementPointConfigId); + TempData["SuccessMessage"] = "Placement points removed."; + } + catch (KeyNotFoundException) + { + return NotFound(); + } return RedirectToAction(nameof(Index)); } } diff --git a/src/SportsDivision.Web/Controllers/StudentController.cs b/src/SportsDivision.Web/Controllers/StudentController.cs index cead688..a90af7d 100644 --- a/src/SportsDivision.Web/Controllers/StudentController.cs +++ b/src/SportsDivision.Web/Controllers/StudentController.cs @@ -20,26 +20,24 @@ public class StudentController : Controller public async Task Index(int? schoolId, string? search, int page = 1, int pageSize = PaginationHelper.PageSize) { - IEnumerable 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 Details(int id) @@ -60,6 +58,7 @@ public class StudentController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpGet] public async Task Create() { @@ -67,6 +66,7 @@ public class StudentController : Controller return View(new StudentCreateDto()); } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Create(StudentCreateDto dto) @@ -91,6 +91,7 @@ public class StudentController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpGet] public async Task Edit(int id) { @@ -123,6 +124,7 @@ public class StudentController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(StudentUpdateDto dto) @@ -151,6 +153,7 @@ public class StudentController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Delete(int id) diff --git a/src/SportsDivision.Web/Controllers/TournamentController.cs b/src/SportsDivision.Web/Controllers/TournamentController.cs index 1f0ecc6..d3f3df0 100644 --- a/src/SportsDivision.Web/Controllers/TournamentController.cs +++ b/src/SportsDivision.Web/Controllers/TournamentController.cs @@ -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 Create(TournamentCreateDto dto) @@ -89,6 +91,7 @@ public class TournamentController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpGet] public async Task Edit(int id) { @@ -118,6 +121,7 @@ public class TournamentController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Edit(TournamentUpdateDto dto) @@ -144,6 +148,7 @@ public class TournamentController : Controller } } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Delete(int id) @@ -165,6 +170,7 @@ public class TournamentController : Controller return RedirectToAction(nameof(Index)); } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task 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 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 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 Archive(int id) @@ -249,6 +258,7 @@ public class TournamentController : Controller return RedirectToAction(nameof(Index)); } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task Unarchive(int id) @@ -270,6 +280,7 @@ public class TournamentController : Controller return RedirectToAction(nameof(Index)); } + [Authorize(Roles = "Admin,Official")] [HttpPost] [ValidateAntiForgeryToken] public async Task ToggleAgeWaiver(int tournamentEventLevelId, int id) diff --git a/src/SportsDivision.Web/Controllers/TrackEventController.cs b/src/SportsDivision.Web/Controllers/TrackEventController.cs index 114634e..4d875ae 100644 --- a/src/SportsDivision.Web/Controllers/TrackEventController.cs +++ b/src/SportsDivision.Web/Controllers/TrackEventController.cs @@ -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 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 CompleteHeat(int heatId) + public async Task CompleteHeat(int heatId, int roundId) { await _heatManagementService.CompleteHeatAsync(heatId); - return RedirectToAction(nameof(ManageRound), new { roundId = heatId }); + return RedirectToAction(nameof(ManageRound), new { roundId }); } [HttpPost] diff --git a/src/SportsDivision.Web/Controllers/UserManagementController.cs b/src/SportsDivision.Web/Controllers/UserManagementController.cs index 61fcff6..66d4e51 100644 --- a/src/SportsDivision.Web/Controllers/UserManagementController.cs +++ b/src/SportsDivision.Web/Controllers/UserManagementController.cs @@ -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(); + foreach (var roleName in roleNames) + { + foreach (var member in await _userManager.GetUsersInRoleAsync(roleName)) + { + roleByUserId.TryAdd(member.Id, roleName); + } + } + var userDtos = new List(); 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)); } + /// True when is an active Admin and no other active Admin exists. + private async Task 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 ResetPassword(string id) { diff --git a/src/SportsDivision.Web/Helpers/PaginationHelper.cs b/src/SportsDivision.Web/Helpers/PaginationHelper.cs index cdec86a..b7db6dc 100644 --- a/src/SportsDivision.Web/Helpers/PaginationHelper.cs +++ b/src/SportsDivision.Web/Helpers/PaginationHelper.cs @@ -34,4 +34,27 @@ public static class PaginationHelper if (showAll) return list.ToList(); return list.Skip((page - 1) * pageSize).Take(pageSize).ToList(); } + + /// + /// Records paging metadata for data that was already paged at the database + /// (see ), without + /// materialising the full result set. Returns the clamped page number. + /// + 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; + } + + /// Coerces a requested page size to one of the offered choices. + public static int NormalizePageSize(int pageSize) => + PageSizeOptions.Contains(pageSize) ? pageSize : PageSize; } diff --git a/src/SportsDivision.Web/Program.cs b/src/SportsDivision.Web/Program.cs index f3304ce..e15c0f7 100644 --- a/src/SportsDivision.Web/Program.cs +++ b/src/SportsDivision.Web/Program.cs @@ -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(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"); diff --git a/src/SportsDivision.Web/Reports/ScoreSheetDocument.cs b/src/SportsDivision.Web/Reports/QuestPdfLicenseInitializer.cs similarity index 100% rename from src/SportsDivision.Web/Reports/ScoreSheetDocument.cs rename to src/SportsDivision.Web/Reports/QuestPdfLicenseInitializer.cs diff --git a/src/SportsDivision.Web/Reports/ReportExcelExporter.cs b/src/SportsDivision.Web/Reports/ReportExcelExporter.cs index cde2403..e9cfbb0 100644 --- a/src/SportsDivision.Web/Reports/ReportExcelExporter.cs +++ b/src/SportsDivision.Web/Reports/ReportExcelExporter.cs @@ -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 data, string tournamentName) { diff --git a/src/SportsDivision.Web/ViewModels/Placeholder.cs b/src/SportsDivision.Web/ViewModels/Placeholder.cs deleted file mode 100644 index b351bde..0000000 --- a/src/SportsDivision.Web/ViewModels/Placeholder.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace SportsDivision.Web.ViewModels; - -// Placeholder to establish namespace — will be replaced with actual ViewModels diff --git a/src/SportsDivision.Web/Views/Event/Index.cshtml b/src/SportsDivision.Web/Views/Event/Index.cshtml index f3db9bc..e779f29 100644 --- a/src/SportsDivision.Web/Views/Event/Index.cshtml +++ b/src/SportsDivision.Web/Views/Event/Index.cshtml @@ -1,20 +1,33 @@ @model IEnumerable @{ ViewData["Title"] = "Events"; + var selectedCategory = ViewBag.SelectedCategory as EventCategory?; + var categories = selectedCategory.HasValue + ? new[] { selectedCategory.Value } + : Enum.GetValues(); }

Events

- Add Event +
+
+ +
+ Add Event +
- -
- @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; }
@category Events
@@ -22,11 +35,19 @@ @foreach (var e in catEvents) {
  • - @e.Name + @e.Name @if (e.IsRelay) { Relay } @if (e.PrimarySchool) { P } @if (e.SecondarySchool) { S } + @if (!e.IsActive) { Inactive } + + + Edit +
    + +
  • } diff --git a/src/SportsDivision.Web/Views/FieldEvent/Index.cshtml b/src/SportsDivision.Web/Views/FieldEvent/Index.cshtml index f53b1c9..bc80e76 100644 --- a/src/SportsDivision.Web/Views/FieldEvent/Index.cshtml +++ b/src/SportsDivision.Web/Views/FieldEvent/Index.cshtml @@ -23,7 +23,6 @@
    -
    diff --git a/src/SportsDivision.Web/Views/HighJump/Index.cshtml b/src/SportsDivision.Web/Views/HighJump/Index.cshtml index 20109d3..b05e6e8 100644 --- a/src/SportsDivision.Web/Views/HighJump/Index.cshtml +++ b/src/SportsDivision.Web/Views/HighJump/Index.cshtml @@ -28,8 +28,6 @@
    - -
    @@ -37,12 +35,13 @@ - @foreach (var height in Model.OrderBy(h => h.SortOrder)) + @foreach (var height in Model.OrderBy(h => h.Height)) { @@ -57,38 +56,30 @@ - @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); } diff --git a/src/SportsDivision.Web/Views/Registration/ByStudent.cshtml b/src/SportsDivision.Web/Views/Registration/ByStudent.cshtml index 10710e0..89defe2 100644 --- a/src/SportsDivision.Web/Views/Registration/ByStudent.cshtml +++ b/src/SportsDivision.Web/Views/Registration/ByStudent.cshtml @@ -1,10 +1,10 @@ @model IEnumerable @{ ViewData["Title"] = "Student Registrations"; - var studentName = ViewBag.StudentName as string; + var student = ViewBag.Student as StudentDto; } -

    Registrations for @studentName

    +

    Registrations for @(student?.FullName ?? "student")


    Student School @height.Height.ToString("0.00")m
    +
    @reg.StudentName @reg.SchoolName - @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) { O } - else if (a == HighJumpAttemptResult.Fail) { X } - else if (a == HighJumpAttemptResult.Pass) { - } - } - @if (attempt.IsEliminated) {
    OUT } - } - else - { - @for (int i = 1; i <= 3; i++) - { -
    - - - - - -
    - } + var current = i == 1 ? attempt?.Attempt1 : i == 2 ? attempt?.Attempt2 : attempt?.Attempt3; +
    + + + + + +
    } + @if (attempt?.IsEliminated == true) {
    OUT }
    diff --git a/src/SportsDivision.Web/Views/Registration/Index.cshtml b/src/SportsDivision.Web/Views/Registration/Index.cshtml index 50d8ffc..12d9829 100644 --- a/src/SportsDivision.Web/Views/Registration/Index.cshtml +++ b/src/SportsDivision.Web/Views/Registration/Index.cshtml @@ -17,7 +17,6 @@ } -
    diff --git a/src/SportsDivision.Web/Views/Registration/Register.cshtml b/src/SportsDivision.Web/Views/Registration/Register.cshtml index 8f332e5..0df2c4c 100644 --- a/src/SportsDivision.Web/Views/Registration/Register.cshtml +++ b/src/SportsDivision.Web/Views/Registration/Register.cshtml @@ -1,29 +1,55 @@ +@model EventRegistrationCreateDto @{ ViewData["Title"] = "Register Student"; var students = ViewBag.Students as IEnumerable; 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; }

    Register Student

    -

    @eventName - @levelName

    +@if (!string.IsNullOrEmpty(eventName)) +{ +

    @eventName - @levelName

    +}
    - -
    +
    - Delete + } }
    +
    @@ -61,10 +110,13 @@ +
    + +
    } } - + @if (placements != null) { @@ -73,13 +125,32 @@ - + } }
    PlacePointsAction
    PlacePointsActions
    #@p.Placement + + +
    + diff --git a/src/SportsDivision.Web/Views/Shared/_HelpLayout.cshtml b/src/SportsDivision.Web/Views/Shared/_HelpLayout.cshtml index 7aba98a..137e561 100644 --- a/src/SportsDivision.Web/Views/Shared/_HelpLayout.cshtml +++ b/src/SportsDivision.Web/Views/Shared/_HelpLayout.cshtml @@ -8,7 +8,7 @@ - + - - - - - - -
    -

    Dominica Sports Division

    -
    School Athletics Tournament Management System — User Guide
    -
    Version 1.0 • February 2026
    -
    - -
    - - - - - - - - - -
    -

    1. Getting Started

    - -

    Logging In

    -
      -
    1. Open the application in your web browser. You will see the home page with a Sign In button.
    2. -
    3. Click Sign In to go to the login page.
    4. -
    5. Enter your Email and Password provided by your administrator.
    6. -
    7. Optionally check Remember Me to stay logged in between sessions.
    8. -
    9. Click Login. You will be taken to the Dashboard.
    10. -
    - -
    - First-time users - Your account is created by an administrator. If you do not have login credentials, contact your system administrator. -
    - -

    Logging Out

    -

    Click your name or initials in the top-right corner of the screen, then select Logout from the dropdown menu.

    - -

    Access Denied

    -

    If you try to access a page you don't have permission for, you will see an "Access Denied" message. This means the feature is restricted to a different role. Contact your administrator if you believe this is an error.

    -
    - - - - -
    -

    2. Roles & Permissions

    -

    The system has five user roles. Each role determines what features you can access. Every authenticated user can view the Dashboard, manage tournaments, schools, students, events, registrations, scoring, and reports. Administrative features are restricted to the Admin role.

    - -
    -
    -

    Admin Administrator

    -

    Full system access including user and scoring configuration management.

    -
      -
    • All standard features
    • -
    • Create & manage user accounts
    • -
    • Configure scoring constants
    • -
    • Modify placement point values
    • -
    -
    -
    -

    Official Sports Official

    -

    Manages tournaments, records scores, and generates reports.

    -
      -
    • Create & manage tournaments
    • -
    • Record track, field, and high jump scores
    • -
    • Manage registrations
    • -
    • Generate all reports
    • -
    -
    -
    -

    Coach School Coach

    -

    Manages students and registrations for their school.

    -
      -
    • View tournaments & events
    • -
    • Register students for events
    • -
    • View scores & reports
    • -
    • Manage student information
    • -
    -
    -
    -

    Principal School Principal

    -

    Oversees school participation and reviews results.

    -
      -
    • View tournaments & events
    • -
    • Monitor school registrations
    • -
    • View scores & standings
    • -
    • Generate school reports
    • -
    -
    -
    -

    Student Student Athlete

    -

    Views tournament information and personal results.

    -
      -
    • View tournaments & events
    • -
    • Check registration status
    • -
    • View personal scores
    • -
    • View standings & reports
    • -
    -
    -
    - -
    - Admin-only features - The Users and Scoring Config sections in the sidebar are only visible to users with the Admin role. If you cannot see these menu items, your account does not have administrator privileges. -
    -
    - - - - - - - - - -
    -

    4. Dashboard

    -

    All Roles

    - -

    The Dashboard is your home screen after logging in. It provides a quick overview of the system.

    - -

    Summary Cards

    -

    At the top of the dashboard, four cards display key statistics:

    -
      -
    • Total Students — Number of registered students, with male/female breakdown
    • -
    • Total Schools — Number of schools in the system
    • -
    • Active Tournaments — Tournaments currently in progress or open for registration
    • -
    • Total Registrations — Number of event registrations across all tournaments
    • -
    - -

    School Standings

    -

    Below the summary cards, a table shows the school standings for the selected tournament, including:

    -
      -
    • School name and abbreviation
    • -
    • Total accumulated points
    • -
    • Number of 1st, 2nd, and 3rd place finishes
    • -
    - -

    Tournament Selector

    -

    Use the dropdown at the top of the standings section to switch between different tournaments and view their respective standings.

    -
    - - - - -
    -

    5. Tournament Management

    -

    All Roles

    - -

    Tournament Lifecycle

    -

    Every tournament follows a defined status progression:

    - -
    - Draft - - Registration - - In Progress - - Completed - - Archived -
    - -

    Viewing Tournaments

    -

    The Tournaments page lists all tournaments. Use the status filter buttons at the top to show only tournaments with a specific status. Toggle Show Archived to include archived tournaments in the list. Each row shows the tournament name, dates, status, and event count.

    - -

    Creating a Tournament

    -
      -
    1. Click the Add Tournament button on the Tournaments page.
    2. -
    3. Fill in the form: Name, Start Date, End Date, Zone, and School Level (Primary or Secondary).
    4. -
    5. Click Create. The tournament is created in Draft status.
    6. -
    - -

    Setting Up Event Levels

    -

    Before opening registration, you must configure which events and levels are included in the tournament.

    -
      -
    1. Open the tournament's Details page.
    2. -
    3. Click Setup Event Levels.
    4. -
    5. Select an Event and Level combination and click Add.
    6. -
    7. Repeat for all desired event-level combinations.
    8. -
    9. To remove one, click the Remove button next to it.
    10. -
    - -

    Managing Tournament Status

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ActionFrom StatusTo StatusWhat Happens
    Open RegistrationDraftRegistrationStudents can now be registered for events
    Start TournamentRegistrationIn ProgressRegistration closes, scoring can begin
    CompleteIn ProgressCompletedScoring finalized, final reports available
    ArchiveCompletedArchivedTournament hidden from default views
    - -

    Age Waiver

    -

    On the tournament details page, you can toggle an age waiver for specific event levels. This bypasses age eligibility checks during registration for that event level.

    - -

    Editing & Deleting

    -

    Use the Edit button to modify tournament details. Use Delete to permanently remove a tournament (only possible while in Draft status).

    -
    - - - - -
    -

    6. School Management

    -

    All Roles

    - -

    Viewing Schools

    -

    The Schools page lists all registered schools. Filter by:

    -
      -
    • Zone — Geographic zone (e.g., NW, N, NE, SE, S, SW, WC, SSC)
    • -
    • School Level — Primary or Secondary
    • -
    -

    Each row shows the school name, abbreviation, level, zone, and number of enrolled students.

    - -

    Adding a School

    -
      -
    1. Click Add School.
    2. -
    3. Fill in Name, Short Name (abbreviation), School Level (Primary/Secondary), and Zone.
    4. -
    5. Click Create.
    6. -
    - -

    Editing a School

    -

    Click the Edit button next to a school to change its details. You can also toggle the Active status to deactivate a school without deleting it.

    -
    - - - - -
    -

    7. Student Management

    -

    All Roles

    - -

    Viewing Students

    -

    The Students page lists all athletes. Filter by:

    -
      -
    • School — Select a specific school
    • -
    • Search — Type a name to search
    • -
    -

    Each row shows the student ID, full name, date of birth, sex, and school.

    - -

    Adding a Student

    -
      -
    1. Click Add Student.
    2. -
    3. Fill in: Student ID (external/existing ID), First Name, Last Name, Date of Birth, Sex (Male/Female), and School.
    4. -
    5. Click Create.
    6. -
    - -
    - Tip - Ensure the date of birth is accurate — the system uses it to determine age-based event level eligibility during registration. -
    - -

    Student Details

    -

    Click a student's name to view their details page, which shows all their event registrations across tournaments.

    -
    - - - - -
    -

    8. Event Configuration

    -

    All Roles

    - -

    Events are the athletic competitions (e.g., 100m Dash, Long Jump, High Jump). They are organized into three categories:

    - - - - - - - - - - - - - - - - - - - - - - -
    CategoryExamplesScoring Method
    Track80m, 100m, 200m, 400m, 1500m, 3000m, 5000m, 80mH, RelaysTimed with heats, semi-finals, finals
    FieldLong Jump, Triple Jump, Shot Put, Discus, Javelin, Cricket BallDistance/weight measured, converted to points
    High JumpHigh JumpHeight-based with O/X/Pass attempts
    - -

    Event Levels

    -

    Each event can be run at different age/gender levels:

    -
      -
    • Primary: Junior Boys, Junior Girls, Senior Boys, Senior Girls
    • -
    • Secondary: U14, U15, U16, U17, U20, U21, Open — each for Boys and Girls
    • -
    - -

    Managing Events

    -

    The Events page shows all events grouped by category. Indicators show which school levels the event applies to: P = Primary, S = Secondary. Events can be created, edited, or deleted. When creating an event, specify whether it is a relay and which school levels it applies to.

    -
    - - - - -
    -

    9. Registration

    -

    All Roles

    - -

    Registration connects students to tournament events. A tournament must be in Registration status for new registrations to be added.

    - -

    Registering a Student

    -
      -
    1. Navigate to the tournament's details page and find the event level you want.
    2. -
    3. Click View Registrations for that event level.
    4. -
    5. Click Register Student.
    6. -
    7. Select the student from the list. The system checks eligibility automatically based on age, sex, and school level.
    8. -
    9. Confirm the registration.
    10. -
    - -
    - Eligibility rules -
      -
    • The student's sex must match the event level (Boys/Girls).
    • -
    • For secondary events, the student's age must fall within the level's range.
    • -
    • The student's school level must match the event's school level.
    • -
    • If an age waiver is enabled on the event level, age restrictions are bypassed.
    • -
    -
    - -

    Viewing Registrations

    -

    Each event level's registration page shows all registered students, their school, registration date, and (once scored) their score and placement.

    - -

    Viewing by Student

    -

    From a student's details page, you can view all events they are registered for across all tournaments.

    - -

    Unregistering

    -

    To remove a student from an event, click the Unregister button next to their name in the registration list.

    -
    - - - - -
    -

    10. Scoring: Track Events

    -

    All Roles

    - -

    Track events use a rounds-based system with heats, advancement, and finals.

    - -

    Workflow Overview

    -
      -
    1. Create Rounds — Set up the round structure: Heats, Semi-Final, and/or Final. For each round, specify the number to advance (Top N) and fastest losers.
    2. -
    3. Seed Heats — Click Seed Heats to automatically distribute registered competitors into heats using random seeding.
    4. -
    5. Record Times — For each heat, enter the time (in seconds) for each lane. Mark any special statuses: -
        -
      • DNS — Did Not Start
      • -
      • DNF — Did Not Finish
      • -
      • DQ — Disqualified
      • -
      -
    6. -
    7. Save Times — Click Save Times to record the results for a heat.
    8. -
    9. Calculate Advancement — Once all heats in a round are complete, click Calculate Advancement to determine which athletes advance to the next round.
    10. -
    11. Populate Next Round — Click Populate Next Round to fill the next round's heats with advancing athletes.
    12. -
    13. Complete — Mark each heat and round as complete when finished. Repeat until the Final round is scored.
    14. -
    - -

    Round Types

    - - - - - - - - - - - - - - - - - -
    RoundPurpose
    HeatsQualifying round. Top N and fastest losers advance.
    Semi-FinalIntermediate round (optional). Further narrows the field.
    FinalChampionship round. Determines placements and points.
    - -
    - Tip - For small fields (8 or fewer athletes), you may skip directly to a Final round without heats. -
    -
    - - - - -
    -

    11. Scoring: Field Events

    -

    All Roles

    - -

    Field events measure distance or weight and convert raw performances to points.

    - -

    Scoring Workflow

    -
      -
    1. Select Event — Navigate to Field Events in the sidebar and select the tournament, event, and level.
    2. -
    3. Record Performances — For each registered athlete, enter their best raw performance (distance in meters or throw distance). Click Record Score to save each entry.
    4. -
    5. Calculate Scores — Once all performances are entered, click Calculate Scores. The system converts raw performances to points using World Athletics scoring constants (formula: Points = A × (B − Performance)C).
    6. -
    7. Calculate Placements — Click Calculate Placements to rank competitors and assign placement points (1st = 10 pts, 2nd = 8 pts, 3rd = 6 pts, etc.).
    8. -
    - -

    Results View

    -

    After scoring, the field event page displays each competitor's raw performance, calculated points, and final placement with placement points earned.

    -
    - - - - -
    -

    12. Scoring: High Jump

    -

    All Roles

    - -

    High Jump uses a unique grid-based scoring interface where heights are columns and competitors are rows.

    - -

    Scoring Workflow

    -
      -
    1. Add Heights — Click Add Height to define the starting height and subsequent increments. Each height becomes a column in the scoring grid.
    2. -
    3. Record Attempts — For each competitor at each height, record the attempt result by clicking the corresponding cell: -
        -
      • O — Clear (successfully jumped the height)
      • -
      • X — Fail (did not clear)
      • -
      • — Pass (chose not to attempt this height)
      • -
      -
    4. -
    5. Elimination — The system automatically marks athletes as OUT after three consecutive failures.
    6. -
    7. Calculate Results — When the competition is complete, click Calculate Results to determine final placements based on the highest height cleared.
    8. -
    - -
    - Tiebreaker rules - When two or more athletes clear the same maximum height, the system uses standard high jump tiebreaker rules: fewest failures at the final cleared height, then fewest total failures across all heights. -
    - -

    Removing Heights

    -

    If a height was added in error, click Remove Height next to it (only possible if no attempts have been recorded at that height).

    -
    - - - - -
    -

    13. Reports

    -

    All Roles

    - -

    The Reports section provides printable and downloadable reports for tournament analysis. Each report can be viewed on screen (HTML) or exported as a PDF.

    - -

    Available Reports

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ReportDescriptionFilters
    Popular EventsShows events ranked by number of registrationsTournament
    Registration by GenderEvent registrations broken down by male/femaleTournament, Zone (optional)
    Event School ReportStudents per school for each eventTournament, Event, Level (optional)
    Students by SchoolList of students and their events, grouped by schoolTournament, School, Zone (optional)
    Scores by EventEvent results with scores and placementsTournament, Event, Level (optional)
    Student PointsIndividual student point totals across all eventsTournament, School, Zone (optional)
    School StandingsOverall school standings with total points and placement countsTournament
    - -

    Generating a Report

    -
      -
    1. Navigate to Reports from the sidebar.
    2. -
    3. Select the report type from the list.
    4. -
    5. Choose the tournament and any optional filters (zone, school, event, level).
    6. -
    7. Click View Report to see the HTML version on screen.
    8. -
    9. Click Download PDF to export a formatted PDF document suitable for printing or distribution.
    10. -
    - -
    - Tip - The School Standings report is particularly useful for award ceremonies. The Students by School report is ideal for distributing to coaches before a tournament begins. -
    -
    - - - - -
    -

    14. User Management

    -

    Admin Only

    - -

    The User Management page allows administrators to create, edit, and manage user accounts and their roles.

    - -

    Viewing Users

    -

    The user list shows all accounts with their name, email, role, associated school, and active status. Use the filters at the top to narrow by:

    -
      -
    • Role — Show only users with a specific role
    • -
    • Status — Show only Active or Inactive users
    • -
    - -

    Creating a User

    -
      -
    1. Click Add User.
    2. -
    3. Fill in: Email, Password, First Name, Last Name.
    4. -
    5. Select a Role from the dropdown (Admin, Official, Coach, Principal, or Student).
    6. -
    7. If the role is Coach, Principal, or Student, a School dropdown appears — select the user's school.
    8. -
    9. Click Create User.
    10. -
    - -
    - Password requirements - Passwords must meet ASP.NET Identity's default policy: at least 6 characters, with uppercase, lowercase, digit, and special character requirements. -
    - -

    Editing a User

    -
      -
    1. Click the Edit (pencil icon) button next to the user.
    2. -
    3. Modify the name, role, school assignment, or active status as needed.
    4. -
    5. Click Save Changes.
    6. -
    -

    Note: The email address cannot be changed after account creation. Passwords cannot be changed from this screen.

    - -

    Activating / Deactivating Users

    -

    To quickly toggle a user's active status without opening the edit form, click the activate/deactivate button (person icon) in the Actions column. Active users have a green badge; inactive users have a red badge.

    -

    Deactivated users cannot log in to the system.

    -
    - - - - -
    -

    15. Scoring Configuration

    -

    Admin Only

    - -

    The Scoring Configuration page allows administrators to adjust the mathematical constants used to convert raw athletic performances into points, and to set placement point values.

    - -

    World Athletics Scoring Constants

    -

    Field events use the World Athletics formula to convert performances to points:

    -

    - Points = A × (B − Performance)C -

    -

    Each event has three configurable coefficients: A, B, and C. These are pre-populated with standard values but can be adjusted for local scoring requirements.

    - -

    Configurable Events

    -

    The following events have scoring constants that can be modified:

    -
      -
    • Track: 100m, 400m, 1500m, 80mH
    • -
    • Jumps: Long Jump, Triple Jump, High Jump
    • -
    • Throws: Shot Put (3kg, 4kg, 5kg, 6kg), Discus (1kg–1.75kg), Javelin (400g–800g)
    • -
    - -

    Placement Points

    -

    You can modify how many points are awarded for each placement finish:

    - - - - - - - - - - -
    PlaceDefault Points
    1st10
    2nd8
    3rd6
    4th5
    5th4
    6th3
    7th2
    8th1
    - -
    - Caution - Changing scoring constants or placement points will affect future score calculations. Already-calculated scores are not automatically recalculated. If you change values mid-tournament, you may need to recalculate affected events. -
    -
    - - - - -
    -

    16. Quick Reference by Role

    -

    Use this table to quickly find which features are available for your role.

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    FeatureAdminOfficialCoachPrincipalStudent
    Dashboard
    View Tournaments
    Create/Edit Tournaments
    Manage Schools
    Manage Students
    Configure Events
    Register Students
    Score Track Events
    Score Field Events
    Score High Jump
    View/Generate Reports
    Download PDF Reports
    Manage Users
    Scoring Configuration
    - -

    Typical Workflow by Role

    - -

    Admin — System Setup

    -
      -
    1. Create user accounts for officials, coaches, principals
    2. -
    3. Configure scoring constants and placement points
    4. -
    5. Set up schools and zones
    6. -
    7. Create events and event levels
    8. -
    9. All other tasks as needed
    10. -
    - -

    Official — Tournament Day

    -
      -
    1. Create tournament and configure event levels
    2. -
    3. Open registration, then start tournament when ready
    4. -
    5. Score track events: seed heats, record times, calculate advancement
    6. -
    7. Score field events: record performances, calculate scores and placements
    8. -
    9. Score high jump: add heights, record attempts, calculate results
    10. -
    11. Generate reports for standings and awards
    12. -
    13. Complete and archive tournament
    14. -
    - -

    Coach — Team Preparation

    -
      -
    1. Add or verify student athlete profiles
    2. -
    3. Register students for appropriate events during registration period
    4. -
    5. Print Students by School report for team roster
    6. -
    7. Monitor scores and standings during tournament
    8. -
    9. Review Student Points report after completion
    10. -
    - -

    Principal — School Oversight

    -
      -
    1. Review student registrations for the school
    2. -
    3. Monitor school standings on dashboard
    4. -
    5. Generate School Standings report for board meetings
    6. -
    7. Review Students by School and Student Points reports
    8. -
    - -

    Student — Athlete

    -
      -
    1. Check the dashboard for tournament information
    2. -
    3. Verify event registration on your student profile
    4. -
    5. View scores and placements after events
    6. -
    7. Check school standings
    8. -
    -
    - - - - -
    -

    © 2026 Dominica Sports Division. All rights reserved.

    -

    For technical support, contact your system administrator.

    -
    - -
    - -