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