Compare commits

..

2 Commits

Author SHA1 Message Date
94225a3475 Restore credentials in appsettings per owner decision
The committed connection strings, EmailSettings and seed-admin password are
intentional configuration for this project, not a bug. docker-compose no
longer requires env vars; deployment works exactly as before. The seeder
stays config-driven (SeedAdmin block now committed), and env-var overrides
remain available if ever wanted. bug-fixes.md updated to reclassify #15/#16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:33:32 -04:00
810f721e48 Fix all 45 audited bugs from bug-fixes.md
Verified every finding against the code, then fixed correctness (redirects,
advancement reset, duplicate heats, high-jump attempt entry, placement
ranking with tie handling, delete-dependency 500s), security (secrets out of
config, config-driven admin seed, login lockout, role authorization with
school scoping, heat-time IDOR, forwarded headers, last-admin guards),
schema integrity (nullable+filtered ExistingStudentId, unique indexes for
rounds/heats/bar heights via SchemaIntegrityFixes migration), performance
(N+1 removal in high jump/reports/standings/dashboard, SQL-side student
paging), and hygiene (duplicate notifications, auto-dismiss scope, local
bootstrap-icons, orphaned files, test-data.sql tournament creation).

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:30:53 -04:00
91 changed files with 3228 additions and 1591 deletions

1
.gitignore vendored
View File

@@ -19,3 +19,4 @@ Thumbs.db
## Secrets ## Secrets
appsettings.*.local.json appsettings.*.local.json
.env

View File

@@ -2,8 +2,6 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
EXPOSE 80
# Copy project files and restore dependencies # Copy project files and restore dependencies
COPY src/SportsDivision.Domain/SportsDivision.Domain.csproj src/SportsDivision.Domain/ COPY src/SportsDivision.Domain/SportsDivision.Domain.csproj src/SportsDivision.Domain/
COPY src/SportsDivision.Application/SportsDivision.Application.csproj src/SportsDivision.Application/ COPY src/SportsDivision.Application/SportsDivision.Application.csproj src/SportsDivision.Application/
@@ -23,6 +21,8 @@ WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://+:80 ENV ASPNETCORE_URLS=http://+:80
EXPOSE 80
COPY --from=build /app/out . COPY --from=build /app/out .
ENTRYPOINT ["dotnet", "SportsDivision.Web.dll"] ENTRYPOINT ["dotnet", "SportsDivision.Web.dll"]

348
bug-fixes.md Normal file
View File

@@ -0,0 +1,348 @@
# 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.
---
## Notes
1. **Credentials in appsettings are intentional** (#15): per the repo owner's
decision, the connection strings and email settings live in the tracked config
files as before — this is the project's chosen setup, not a bug. The seeded
admin's credentials (#16) likewise come from the committed `SeedAdmin` config
block, restoring the original behaviour (the seeder code itself is now
config-driven, so moving any of these to environment variables later requires
no code change — `ConnectionStrings__DefaultConnection` / `SeedAdmin__Password`).
2. **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` (110) **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. Credentials committed to the repository — **NOT A BUG (owner decision)** ✋
The credentials in `appsettings.json` / `appsettings.Production.json` are the
project's intentional configuration, per the repo owner. They remain in place.
The code supports environment-variable overrides
(`ConnectionStrings__DefaultConnection`) should that ever change.
### 16. Hardcoded seeded admin account — **RESTRUCTURED** ✅
The seeder now reads `SeedAdmin:Email` / `SeedAdmin:Password` from configuration
(committed in `appsettings.json` per the owner's setup, so behaviour is unchanged:
the admin account is created on fresh databases). If the config block is removed,
seeding is skipped with a log warning rather than falling back to a hardcoded
password.
### 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
`<partial name="_Notification" />` 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<EventCategory>()` 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 fixed in code**; #15 reclassified as intentional configuration per the repo
owner (not a bug); #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.
Configuration and deployment work exactly as before (credentials in
appsettings; no environment variables required).

View File

@@ -3,13 +3,11 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: registry.dwarrington.com/sportsdivision:${IMAGE_TAG} image: registry.dwarrington.com/sportsdivision:${IMAGE_TAG:-latest}
container_name: sportsdivision container_name: sportsdivision
restart: always restart: always
ports: ports:
- "5039:80" - "5039:80"
volumes:
- ./data:/app/data
networks: networks:
- caddy_network - caddy_network

View File

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

View File

@@ -1,3 +1,4 @@
namespace SportsDivision.Application.DTOs; 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 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 ScoringConstantUpdateDto { public int ScoringConstantId { get; set; } public decimal A { get; set; } public decimal B { get; set; } public decimal C { get; set; } }
public class ScoringConstantCreateDto { public int EventId { get; set; } public decimal A { get; set; } public decimal B { get; set; } public decimal C { get; set; } public string Unit { get; set; } = string.Empty; }

View File

@@ -1,5 +1,5 @@
using SportsDivision.Domain.Enums; using SportsDivision.Domain.Enums;
namespace SportsDivision.Application.DTOs; 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 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; } = 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 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; } } public class StudentUpdateDto : StudentCreateDto { public int StudentId { get; set; } public bool IsActive { get; set; } }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,13 @@ public class EventService : IEventService
{ {
var evt = await _uow.Events.GetByIdAsync(id) var evt = await _uow.Events.GetByIdAsync(id)
?? throw new KeyNotFoundException("Event not found."); ?? 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); _uow.Events.Remove(evt);
await _uow.SaveChangesAsync(); await _uow.SaveChangesAsync();
} }

View File

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

View File

@@ -26,7 +26,12 @@ public class HighJumpService : IHighJumpService
public async Task<HighJumpHeightDto> AddHeightAsync(HighJumpHeightCreateDto dto) public async Task<HighJumpHeightDto> AddHeightAsync(HighJumpHeightCreateDto dto)
{ {
if (dto.Height <= 0)
throw new InvalidOperationException("Height must be greater than zero.");
var existingHeights = await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(dto.TournamentEventLevelId); 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 maxOrder = existingHeights.Any() ? existingHeights.Max(h => h.SortOrder) : 0;
var height = new HighJumpHeight var height = new HighJumpHeight
@@ -54,6 +59,13 @@ public class HighJumpService : IHighJumpService
var height = await _uow.HighJumpHeights.GetWithAttemptsAsync(dto.HighJumpHeightId) var height = await _uow.HighJumpHeights.GetWithAttemptsAsync(dto.HighJumpHeightId)
?? throw new KeyNotFoundException("Height not found."); ?? 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); var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == dto.EventRegistrationId);
if (attempt == null) if (attempt == null)
{ {
@@ -78,21 +90,17 @@ public class HighJumpService : IHighJumpService
public async Task<bool> IsEliminatedAsync(int tournamentEventLevelId, int eventRegistrationId) public async Task<bool> IsEliminatedAsync(int tournamentEventLevelId, int eventRegistrationId)
{ {
// Attempts are eager-loaded by the repository; bars are judged in height order.
var heights = await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId); var heights = await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId);
int consecutiveFails = 0; 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); var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == eventRegistrationId);
if (fullHeight == null) continue;
var attempt = fullHeight.Attempts.FirstOrDefault(a => a.EventRegistrationId == eventRegistrationId);
if (attempt == null) continue; if (attempt == null) continue;
if (attempt.HasCleared) if (attempt.HasCleared)
consecutiveFails = 0; consecutiveFails = 0;
else if (attempt.IsEliminated)
consecutiveFails += 3;
else else
consecutiveFails += attempt.FailCount; consecutiveFails += attempt.FailCount;
@@ -105,8 +113,10 @@ public class HighJumpService : IHighJumpService
public async Task<string?> CalculateResultsAsync(int tournamentEventLevelId, string recordedBy) public async Task<string?> CalculateResultsAsync(int tournamentEventLevelId, string recordedBy)
{ {
// "Highest bar" is the bar with the greatest height, regardless of the order
// the bars were entered; attempts are already eager-loaded by the repository.
var heights = (await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId)) var heights = (await _uow.HighJumpHeights.GetByTournamentEventLevelAsync(tournamentEventLevelId))
.OrderBy(h => h.SortOrder).ToList(); .OrderBy(h => h.Height).ToList();
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId) var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
?? throw new KeyNotFoundException("Tournament event level not found."); ?? throw new KeyNotFoundException("Tournament event level not found.");
@@ -121,10 +131,7 @@ public class HighJumpService : IHighJumpService
foreach (var height in heights) foreach (var height in heights)
{ {
var fullHeight = await _uow.HighJumpHeights.GetWithAttemptsAsync(height.HighJumpHeightId); var attempt = height.Attempts.FirstOrDefault(a => a.EventRegistrationId == reg.EventRegistrationId);
if (fullHeight == null) continue;
var attempt = fullHeight.Attempts.FirstOrDefault(a => a.EventRegistrationId == reg.EventRegistrationId);
if (attempt == null) continue; if (attempt == null) continue;
totalFails += attempt.FailCount; totalFails += attempt.FailCount;
@@ -153,6 +160,14 @@ public class HighJumpService : IHighJumpService
.ToDictionary(p => p.Placement, p => p.Points); .ToDictionary(p => p.Placement, p => p.Points);
var constant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId); 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 // Assign placements with proper tie handling. Athletes identical on all three
// countback keys are a genuine tie: they share one placement (standard competition // 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 // ranking, e.g. 1, 2, 3, 3, 5) and share placement points — the points for the

View File

@@ -17,6 +17,12 @@ public class RegistrationService : IRegistrationService
_mapper = mapper; _mapper = mapper;
} }
public async Task<EventRegistrationDto?> GetByIdAsync(int eventRegistrationId)
{
var reg = await _uow.EventRegistrations.GetByIdAsync(eventRegistrationId);
return reg == null ? null : _mapper.Map<EventRegistrationDto>(reg);
}
public async Task<IEnumerable<EventRegistrationDto>> GetByTournamentEventLevelAsync(int tournamentEventLevelId) public async Task<IEnumerable<EventRegistrationDto>> GetByTournamentEventLevelAsync(int tournamentEventLevelId)
{ {
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tournamentEventLevelId); var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tournamentEventLevelId);
@@ -31,7 +37,17 @@ public class RegistrationService : IRegistrationService
public async Task<EventRegistrationDto> RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy) public async Task<EventRegistrationDto> RegisterStudentAsync(EventRegistrationCreateDto dto, string registeredBy)
{ {
var (isEligible, reason) = await CheckEligibilityAsync(dto.TournamentEventLevelId, dto.StudentId!.Value); if (!dto.StudentId.HasValue)
{
// Relay teams are modelled in the domain but have no management UI or
// eligibility rules yet, so relay-only registrations are rejected rather
// than crashing on the missing student.
throw new InvalidOperationException(dto.RelayTeamId.HasValue
? "Relay team registration is not supported yet — register individual athletes."
: "Select a student to register.");
}
var (isEligible, reason) = await CheckEligibilityAsync(dto.TournamentEventLevelId, dto.StudentId.Value);
if (!isEligible) if (!isEligible)
throw new InvalidOperationException($"Student is not eligible: {reason}"); throw new InvalidOperationException($"Student is not eligible: {reason}");
@@ -53,6 +69,17 @@ public class RegistrationService : IRegistrationService
{ {
var reg = await _uow.EventRegistrations.GetByIdAsync(eventRegistrationId) var reg = await _uow.EventRegistrations.GetByIdAsync(eventRegistrationId)
?? throw new KeyNotFoundException("Registration not found."); ?? 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); _uow.EventRegistrations.Remove(reg);
await _uow.SaveChangesAsync(); await _uow.SaveChangesAsync();
} }
@@ -65,6 +92,15 @@ public class RegistrationService : IRegistrationService
var student = await _uow.Students.GetByIdAsync(studentId); var student = await _uow.Students.GetByIdAsync(studentId);
if (student == null) return (false, "Student not found."); 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 // Check sex match
if (student.Sex != tel.EventLevel.Sex) if (student.Sex != tel.EventLevel.Sex)
return (false, $"Student sex ({student.Sex}) does not match event level ({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 // Check school level compatibility
var school = await _uow.Schools.GetByIdAsync(student.SchoolId); var school = await _uow.Schools.GetByIdAsync(student.SchoolId);
if (school == null) return (false, "Student's school not found."); 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; bool schoolLevelMatch = tel.EventLevel.SchoolLevel == school.SchoolLevel;
// Allow "compete up" — primary students can enter secondary events, but not the reverse // Allow "compete up" — primary students can enter secondary events, but not the reverse

View File

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

View File

@@ -63,6 +63,13 @@ public class SchoolService : ISchoolService
{ {
var school = await _uow.Schools.GetByIdAsync(id) var school = await _uow.Schools.GetByIdAsync(id)
?? throw new KeyNotFoundException("School not found."); ?? 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); _uow.Schools.Remove(school);
await _uow.SaveChangesAsync(); await _uow.SaveChangesAsync();
} }

View File

@@ -37,27 +37,34 @@ public class ScoringService : IScoringService
var reg = await _uow.EventRegistrations.GetByIdAsync(dto.EventRegistrationId) var reg = await _uow.EventRegistrations.GetByIdAsync(dto.EventRegistrationId)
?? throw new KeyNotFoundException("Registration not found."); ?? throw new KeyNotFoundException("Registration not found.");
var existingScore = await _uow.Scores.GetByRegistrationAsync(dto.EventRegistrationId); // Keep the WA points in step with the mark: a corrected performance must not
if (existingScore != null) // leave the previously calculated points behind.
{ var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(reg.TournamentEventLevelId);
existingScore.RawPerformance = dto.RawPerformance; var constant = tel != null ? await _uow.ScoringConstants.GetByEventAsync(tel.EventId) : null;
existingScore.RecordedBy = recordedBy; bool isTrack = tel?.Event.Category == Domain.Enums.EventCategory.Track;
existingScore.RecordedAt = DateTime.UtcNow; int calculated = constant != null
_uow.Scores.Update(existingScore); ? CalculatePoints(dto.RawPerformance, constant.A, constant.B, constant.C, isTrack)
await _uow.SaveChangesAsync(); : 0;
return _mapper.Map<ScoreDto>(existingScore);
}
var score = new Score var score = await _uow.Scores.GetByRegistrationAsync(dto.EventRegistrationId);
{ var isNew = score == null;
EventRegistrationId = dto.EventRegistrationId, score ??= new Score { EventRegistrationId = dto.EventRegistrationId };
RawPerformance = dto.RawPerformance,
RecordedBy = recordedBy,
RecordedAt = DateTime.UtcNow
};
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(); await _uow.SaveChangesAsync();
// If placements were already calculated for this event, refresh them so a
// corrected mark doesn't leave the standings stale.
var siblingScores = await _uow.Scores.GetByTournamentEventLevelAsync(reg.TournamentEventLevelId);
if (siblingScores.Any(s => s.Placement != null))
await CalculatePlacementsAsync(reg.TournamentEventLevelId);
return _mapper.Map<ScoreDto>(score); return _mapper.Map<ScoreDto>(score);
} }
@@ -66,15 +73,16 @@ public class ScoringService : IScoringService
var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId) var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
?? throw new KeyNotFoundException("Tournament event level not found."); ?? throw new KeyNotFoundException("Tournament event level not found.");
var scoringConstant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId); var scoringConstant = await _uow.ScoringConstants.GetByEventAsync(tel.EventId)
if (scoringConstant == null) return; ?? 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; bool isTrack = tel.Event.Category == Domain.Enums.EventCategory.Track;
foreach (var reg in tel.Registrations) foreach (var reg in tel.Registrations)
{ {
var score = await _uow.Scores.GetByRegistrationAsync(reg.EventRegistrationId); var score = await _uow.Scores.GetByRegistrationAsync(reg.EventRegistrationId);
if (score == null || score.RawPerformance == 0) continue; if (score == null) continue;
score.CalculatedPoints = CalculatePoints( score.CalculatedPoints = CalculatePoints(
score.RawPerformance, score.RawPerformance,
@@ -147,31 +155,68 @@ public class ScoringService : IScoringService
public async Task CalculatePlacementsAsync(int tournamentEventLevelId) public async Task CalculatePlacementsAsync(int tournamentEventLevelId)
{ {
var scores = (await _uow.Scores.GetByTournamentEventLevelAsync(tournamentEventLevelId)) var tel = await _uow.TournamentEventLevels.GetWithRegistrationsAsync(tournamentEventLevelId)
.Where(s => s.RawPerformance > 0) ?? throw new KeyNotFoundException("Tournament event level not found.");
.OrderByDescending(s => s.CalculatedPoints)
.ToList(); // 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()) var placementPoints = (await _uow.PlacementPointConfigs.GetAllAsync())
.ToDictionary(p => p.Placement, p => p.Points); .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; int start = i;
scores[i].PlacementPoints = placementPoints.TryGetValue(i + 1, out var pts) ? pts : 0; int place = start + 1;
_uow.Scores.Update(scores[i]); 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(); await _uow.SaveChangesAsync();
} }
public async Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId) public async Task<IEnumerable<SchoolPointsSummaryDto>> GetSchoolStandingsAsync(int tournamentId)
{ {
var tels = await _uow.TournamentEventLevels.GetByTournamentAsync(tournamentId); // One query for the whole tournament — this runs on every dashboard load.
var regs = await _uow.EventRegistrations.GetByTournamentAsync(tournamentId);
var schoolPoints = new Dictionary<int, SchoolPointsSummaryDto>(); var schoolPoints = new Dictionary<int, SchoolPointsSummaryDto>();
foreach (var tel in tels)
{
var regs = await _uow.EventRegistrations.GetByTournamentEventLevelAsync(tel.TournamentEventLevelId);
foreach (var reg in regs) foreach (var reg in regs)
{ {
if (reg.Student == null || reg.Score == null) continue; if (reg.Student == null || reg.Score == null) continue;
@@ -193,7 +238,6 @@ public class ScoringService : IScoringService
else if (reg.Score.Placement == 2) summary.SecondPlaceCount++; else if (reg.Score.Placement == 2) summary.SecondPlaceCount++;
else if (reg.Score.Placement == 3) summary.ThirdPlaceCount++; else if (reg.Score.Placement == 3) summary.ThirdPlaceCount++;
} }
}
return schoolPoints.Values return schoolPoints.Values
.OrderByDescending(s => s.TotalPoints) .OrderByDescending(s => s.TotalPoints)
@@ -208,6 +252,32 @@ public class ScoringService : IScoringService
return _mapper.Map<IEnumerable<ScoringConstantDto>>(constants); return _mapper.Map<IEnumerable<ScoringConstantDto>>(constants);
} }
public async Task CreateScoringConstantAsync(ScoringConstantCreateDto dto)
{
var evt = await _uow.Events.GetByIdAsync(dto.EventId)
?? throw new KeyNotFoundException("Event not found.");
if (await _uow.ScoringConstants.GetByEventAsync(dto.EventId) != null)
throw new InvalidOperationException($"{evt.Name} already has a scoring constant — edit the existing one instead.");
await _uow.ScoringConstants.AddAsync(new ScoringConstant
{
EventId = dto.EventId,
A = dto.A,
B = dto.B,
C = dto.C,
Unit = dto.Unit
});
await _uow.SaveChangesAsync();
}
public async Task DeleteScoringConstantAsync(int scoringConstantId)
{
var constant = await _uow.ScoringConstants.GetByIdAsync(scoringConstantId)
?? throw new KeyNotFoundException("Scoring constant not found.");
_uow.ScoringConstants.Remove(constant);
await _uow.SaveChangesAsync();
}
public async Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto) public async Task UpdateScoringConstantAsync(ScoringConstantUpdateDto dto)
{ {
var constant = await _uow.ScoringConstants.GetByIdAsync(dto.ScoringConstantId) var constant = await _uow.ScoringConstants.GetByIdAsync(dto.ScoringConstantId)
@@ -225,6 +295,29 @@ public class ScoringService : IScoringService
return _mapper.Map<IEnumerable<PlacementPointConfigDto>>(configs); return _mapper.Map<IEnumerable<PlacementPointConfigDto>>(configs);
} }
public async Task CreatePlacementPointConfigAsync(PlacementPointConfigCreateDto dto)
{
if (dto.Placement < 1)
throw new InvalidOperationException("Placement must be 1 or higher.");
if (await _uow.PlacementPointConfigs.AnyAsync(p => p.Placement == dto.Placement))
throw new InvalidOperationException($"Placement {dto.Placement} already has points configured — edit the existing entry instead.");
await _uow.PlacementPointConfigs.AddAsync(new PlacementPointConfig
{
Placement = dto.Placement,
Points = dto.Points
});
await _uow.SaveChangesAsync();
}
public async Task DeletePlacementPointConfigAsync(int placementPointConfigId)
{
var config = await _uow.PlacementPointConfigs.GetByIdAsync(placementPointConfigId)
?? throw new KeyNotFoundException("Placement point config not found.");
_uow.PlacementPointConfigs.Remove(config);
await _uow.SaveChangesAsync();
}
public async Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto) public async Task UpdatePlacementPointConfigAsync(PlacementPointConfigDto dto)
{ {
var config = await _uow.PlacementPointConfigs.GetByIdAsync(dto.PlacementPointConfigId) var config = await _uow.PlacementPointConfigs.GetByIdAsync(dto.PlacementPointConfigId)

View File

@@ -41,14 +41,15 @@ public class StudentService : IStudentService
return _mapper.Map<IEnumerable<StudentDto>>(students); return _mapper.Map<IEnumerable<StudentDto>>(students);
} }
public async Task<IEnumerable<StudentDto>> SearchAsync(string searchTerm) public async Task<(IReadOnlyList<StudentDto> Items, int TotalCount)> GetPagedAsync(int? schoolId, string? searchTerm, int page, int pageSize)
{ {
var students = await _uow.Students.SearchAsync(searchTerm); var (students, total) = await _uow.Students.GetPagedAsync(schoolId, searchTerm, page, pageSize);
return _mapper.Map<IEnumerable<StudentDto>>(students); return (_mapper.Map<IReadOnlyList<StudentDto>>(students), total);
} }
public async Task<StudentDto> CreateAsync(StudentCreateDto dto) public async Task<StudentDto> CreateAsync(StudentCreateDto dto)
{ {
await NormalizeAndCheckExistingIdAsync(dto, studentId: null);
var student = _mapper.Map<Student>(dto); var student = _mapper.Map<Student>(dto);
await _uow.Students.AddAsync(student); await _uow.Students.AddAsync(student);
await _uow.SaveChangesAsync(); await _uow.SaveChangesAsync();
@@ -59,15 +60,39 @@ public class StudentService : IStudentService
{ {
var student = await _uow.Students.GetByIdAsync(dto.StudentId) var student = await _uow.Students.GetByIdAsync(dto.StudentId)
?? throw new KeyNotFoundException("Student not found."); ?? throw new KeyNotFoundException("Student not found.");
await NormalizeAndCheckExistingIdAsync(dto, dto.StudentId);
_mapper.Map(dto, student); _mapper.Map(dto, student);
_uow.Students.Update(student); _uow.Students.Update(student);
await _uow.SaveChangesAsync(); 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) public async Task DeleteAsync(int id)
{ {
var student = await _uow.Students.GetByIdAsync(id) var student = await _uow.Students.GetByIdAsync(id)
?? throw new KeyNotFoundException("Student not found."); ?? 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); _uow.Students.Remove(student);
await _uow.SaveChangesAsync(); await _uow.SaveChangesAsync();
} }

View File

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

View File

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

View File

@@ -5,7 +5,8 @@ namespace SportsDivision.Domain.Entities;
public class Student public class Student
{ {
public int StudentId { get; set; } 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 FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty;
public DateOnly DateOfBirth { get; set; } public DateOnly DateOfBirth { get; set; }

View File

@@ -1,8 +1,9 @@
namespace SportsDivision.Domain.Enums; 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 public enum SeedingMethod
{ {
Random, Random
ByPerformance,
Manual
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,8 +9,10 @@ public class StudentConfiguration : IEntityTypeConfiguration<Student>
public void Configure(EntityTypeBuilder<Student> builder) public void Configure(EntityTypeBuilder<Student> builder)
{ {
builder.HasKey(s => s.StudentId); builder.HasKey(s => s.StudentId);
builder.Property(s => s.ExistingStudentId).IsRequired().HasMaxLength(50); builder.Property(s => s.ExistingStudentId).HasMaxLength(50);
builder.HasIndex(s => s.ExistingStudentId).IsUnique(); // 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.FirstName).IsRequired().HasMaxLength(100);
builder.Property(s => s.LastName).IsRequired().HasMaxLength(100); builder.Property(s => s.LastName).IsRequired().HasMaxLength(100);
builder.Property(s => s.Sex).HasConversion<string>().HasMaxLength(10); builder.Property(s => s.Sex).HasConversion<string>().HasMaxLength(10);

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,5 +13,9 @@
"GmailEmail": "pcgurudm@gmail.com", "GmailEmail": "pcgurudm@gmail.com",
"GmailPassword": "bbux tqjo lubq utss", "GmailPassword": "bbux tqjo lubq utss",
"DisplayName": "Sports Division" "DisplayName": "Sports Division"
},
"SeedAdmin": {
"Email": "admin@sportsdivision.dm",
"Password": "Admin@123!"
} }
} }

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,9 @@
-- test-data.sql -- test-data.sql
-- Bulk sample data for the Dominica Sports Division app. -- Bulk sample data for the Dominica Sports Division app.
-- --
-- Adds, for BOTH seeded tournaments (1 = Inter-Zone Championship 2026, -- Creates two sample tournaments if they do not already exist
-- 2 = National Championship 2026): -- (Inter-Zone Championship 2026 and National Championship 2026 — the app's
-- seeder deliberately seeds no tournaments), then adds for BOTH of them:
-- * 60 students (30 male / 30 female) spread across the secondary schools -- * 60 students (30 male / 30 female) spread across the secondary schools
-- * Under-16 Boys & Under-16 Girls event levels for EVERY individual -- * Under-16 Boys & Under-16 Girls event levels for EVERY individual
-- (non-relay) event in the catalogue -- (non-relay) event in the catalogue
@@ -99,12 +100,28 @@ WHERE NOT EXISTS (
); );
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 2. TOURNAMENT EVENT LEVELS (every _ev event x U16 Boys/Girls x 2 tournaments) -- 2. TOURNAMENTS (created idempotently; IDs are resolved by name, never
-- assumed — the application seeder does not create any tournaments)
-- ---------------------------------------------------------------------------
INSERT INTO "Tournaments" ("Name", "StartDate", "EndDate", "SchoolLevel", "IsArchived", "Status")
SELECT v.name, v.sdate::date, v.edate::date, 'Secondary', false, 'InProgress'
FROM (VALUES
('Inter-Zone Championship 2026', '2026-03-16', '2026-03-20'),
('National Championship 2026', '2026-04-13', '2026-04-17')
) AS v(name, sdate, edate)
WHERE NOT EXISTS (SELECT 1 FROM "Tournaments" t WHERE t."Name" = v.name);
CREATE TEMP TABLE _tt ON COMMIT DROP AS
SELECT "TournamentId" AS tid FROM "Tournaments"
WHERE "Name" IN ('Inter-Zone Championship 2026', 'National Championship 2026');
-- ---------------------------------------------------------------------------
-- 3. TOURNAMENT EVENT LEVELS (every _ev event x U16 Boys/Girls x 2 tournaments)
-- Age restriction waived so the U16 sample athletes are always eligible. -- Age restriction waived so the U16 sample athletes are always eligible.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
WITH combos AS ( WITH combos AS (
SELECT t.tid, e."EventId" AS eid, el."EventLevelId" AS lid SELECT t.tid, e."EventId" AS eid, el."EventLevelId" AS lid
FROM (VALUES (1), (2)) AS t(tid) FROM _tt t
CROSS JOIN _ev CROSS JOIN _ev
CROSS JOIN (VALUES ('Under 16 Boys'), ('Under 16 Girls')) AS lv(name) CROSS JOIN (VALUES ('Under 16 Boys'), ('Under 16 Girls')) AS lv(name)
JOIN "Events" e ON e."Name" = _ev.ename JOIN "Events" e ON e."Name" = _ev.ename
@@ -120,14 +137,14 @@ WHERE NOT EXISTS (
); );
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- 3. REGISTRATIONS (~16 sample students per event level, sex-matched) -- 4. REGISTRATIONS (~16 sample students per event level, sex-matched)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
WITH testtels AS ( WITH testtels AS (
SELECT tel."TournamentEventLevelId" AS tid, el."Sex" AS sex SELECT tel."TournamentEventLevelId" AS tid, el."Sex" AS sex
FROM "TournamentEventLevels" tel FROM "TournamentEventLevels" tel
JOIN "EventLevels" el ON el."EventLevelId" = tel."EventLevelId" JOIN "EventLevels" el ON el."EventLevelId" = tel."EventLevelId"
JOIN "Events" e ON e."EventId" = tel."EventId" JOIN "Events" e ON e."EventId" = tel."EventId"
WHERE tel."TournamentId" IN (1, 2) WHERE tel."TournamentId" IN (SELECT tid FROM _tt)
AND el."Name" IN ('Under 16 Boys', 'Under 16 Girls') AND el."Name" IN ('Under 16 Boys', 'Under 16 Girls')
AND e."Name" IN (SELECT ename FROM _ev) AND e."Name" IN (SELECT ename FROM _ev)
), ),
@@ -158,7 +175,7 @@ CREATE TEMP TABLE _tel ON COMMIT DROP AS
FROM "TournamentEventLevels" tel FROM "TournamentEventLevels" tel
JOIN "EventLevels" el ON el."EventLevelId" = tel."EventLevelId" JOIN "EventLevels" el ON el."EventLevelId" = tel."EventLevelId"
JOIN "Events" e ON e."EventId" = tel."EventId" JOIN "Events" e ON e."EventId" = tel."EventId"
WHERE tel."TournamentId" IN (1, 2) WHERE tel."TournamentId" IN (SELECT tid FROM _tt)
AND el."Name" IN ('Under 16 Boys', 'Under 16 Girls') AND el."Name" IN ('Under 16 Boys', 'Under 16 Girls')
AND e."Name" IN (SELECT ename FROM _ev); AND e."Name" IN (SELECT ename FROM _ev);
@@ -338,8 +355,10 @@ COMMIT;
-- Summary (informational; safe to run repeatedly) -- Summary (informational; safe to run repeatedly)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
SELECT 'students (TD-)' AS metric, count(*) AS value FROM "Students" WHERE "ExistingStudentId" LIKE 'TD-%' SELECT 'students (TD-)' AS metric, count(*) AS value FROM "Students" WHERE "ExistingStudentId" LIKE 'TD-%'
UNION ALL SELECT 'event levels (t1)', count(*) FROM "TournamentEventLevels" WHERE "TournamentId" = 1 UNION ALL SELECT 'event levels (both tournaments)', count(*)
UNION ALL SELECT 'event levels (t2)', count(*) FROM "TournamentEventLevels" WHERE "TournamentId" = 2 FROM "TournamentEventLevels" tel
JOIN "Tournaments" t ON t."TournamentId" = tel."TournamentId"
WHERE t."Name" IN ('Inter-Zone Championship 2026', 'National Championship 2026')
UNION ALL SELECT 'registrations (total)', count(*) FROM "EventRegistrations" UNION ALL SELECT 'registrations (total)', count(*) FROM "EventRegistrations"
UNION ALL SELECT 'scores (total)', count(*) FROM "Scores" UNION ALL SELECT 'scores (total)', count(*) FROM "Scores"
UNION ALL SELECT 'track heat lanes', count(*) FROM "HeatLanes" UNION ALL SELECT 'track heat lanes', count(*) FROM "HeatLanes"

View File

@@ -232,6 +232,102 @@ public class RegistrationEligibilityTests
Assert.Contains("already registered", reason!, StringComparison.OrdinalIgnoreCase); Assert.Contains("already registered", reason!, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public async Task CheckEligibility_InactiveStudent_ReturnsFalse()
{
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
var student = CreateStudent(Sex.Male);
student.IsActive = false;
SetupTournamentEventLevel(tel);
SetupStudent(student);
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
Assert.False(isEligible);
Assert.Contains("deactivated", reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CheckEligibility_InactiveSchool_ReturnsFalse()
{
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
var student = CreateStudent(Sex.Male);
var school = CreateSchool(SchoolLevel.Secondary);
school.IsActive = false;
SetupTournamentEventLevel(tel);
SetupStudent(student);
SetupSchool(school);
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
Assert.False(isEligible);
Assert.Contains("school is deactivated", reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CheckEligibility_CompletedTournament_ReturnsFalse()
{
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
tel.Tournament.Status = TournamentStatus.Completed;
var student = CreateStudent(Sex.Male);
SetupTournamentEventLevel(tel);
SetupStudent(student);
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
Assert.False(isEligible);
Assert.Contains("completed", reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task CheckEligibility_ArchivedTournament_ReturnsFalse()
{
var tel = CreateTel(Sex.Male, SchoolLevel.Secondary);
tel.Tournament.IsArchived = true;
var student = CreateStudent(Sex.Male);
SetupTournamentEventLevel(tel);
SetupStudent(student);
var (isEligible, reason) = await _service.CheckEligibilityAsync(1, 1);
Assert.False(isEligible);
Assert.Contains("archived", reason!, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RegisterStudent_RelayOnlyRegistration_ThrowsInsteadOfCrashing()
{
var dto = new Application.DTOs.EventRegistrationCreateDto
{
TournamentEventLevelId = 1,
StudentId = null,
RelayTeamId = 5
};
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _service.RegisterStudentAsync(dto, "tester"));
Assert.Contains("relay", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RegisterStudent_NeitherStudentNorRelay_Throws()
{
var dto = new Application.DTOs.EventRegistrationCreateDto
{
TournamentEventLevelId = 1,
StudentId = null,
RelayTeamId = null
};
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => _service.RegisterStudentAsync(dto, "tester"));
Assert.Contains("student", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact] [Fact]
public async Task CheckEligibility_TelNotFound_ReturnsFalse() public async Task CheckEligibility_TelNotFound_ReturnsFalse()
{ {

View File

@@ -0,0 +1,145 @@
using Moq;
using SportsDivision.Application.Services;
using SportsDivision.Domain.Entities;
using SportsDivision.Domain.Enums;
using SportsDivision.Domain.Interfaces;
namespace SportsDivision.Application.Tests;
public class ScoringPlacementTests
{
private readonly Mock<IUnitOfWork> _mockUow;
private readonly ScoringService _service;
public ScoringPlacementTests()
{
_mockUow = new Mock<IUnitOfWork>();
_service = new ScoringService(_mockUow.Object, null!);
}
private void Setup(EventCategory category, List<Score> scores)
{
_mockUow.Setup(u => u.TournamentEventLevels.GetWithRegistrationsAsync(It.IsAny<int>()))
.ReturnsAsync(new TournamentEventLevel
{
TournamentEventLevelId = 1,
Event = new Event { Category = category }
});
_mockUow.Setup(u => u.Scores.GetByTournamentEventLevelAsync(It.IsAny<int>()))
.ReturnsAsync(scores);
_mockUow.Setup(u => u.PlacementPointConfigs.GetAllAsync())
.ReturnsAsync(new List<PlacementPointConfig>
{
new() { Placement = 1, Points = 10 },
new() { Placement = 2, Points = 8 },
new() { Placement = 3, Points = 6 },
new() { Placement = 4, Points = 5 },
});
_mockUow.Setup(u => u.SaveChangesAsync()).ReturnsAsync(1);
}
private static Score MakeScore(int regId, decimal performance) =>
new() { EventRegistrationId = regId, RawPerformance = performance };
[Fact]
public async Task CalculatePlacements_FieldEvent_RanksByPerformanceDescending()
{
// Points-based ranking used to assign arbitrary places when no scoring
// constant existed (all CalculatedPoints = 0); ranking is now on the mark.
var s1 = MakeScore(1, 10.00m);
var s2 = MakeScore(2, 12.50m);
var s3 = MakeScore(3, 11.00m);
Setup(EventCategory.Field, new List<Score> { s1, s2, s3 });
await _service.CalculatePlacementsAsync(1);
Assert.Equal(1, s2.Placement);
Assert.Equal(2, s3.Placement);
Assert.Equal(3, s1.Placement);
Assert.Equal(10, s2.PlacementPoints);
}
[Fact]
public async Task CalculatePlacements_TrackEvent_RanksAscending()
{
var s1 = MakeScore(1, 11.20m);
var s2 = MakeScore(2, 10.90m);
Setup(EventCategory.Track, new List<Score> { s1, s2 });
await _service.CalculatePlacementsAsync(1);
Assert.Equal(1, s2.Placement);
Assert.Equal(2, s1.Placement);
}
[Fact]
public async Task CalculatePlacements_Ties_SharePlaceAndSplitPooledPoints()
{
// Two identical throws share 1st (competition ranking 1, 1, 3) and split
// the pooled points for places 1 and 2: (10 + 8) / 2 = 9.
var s1 = MakeScore(1, 12.50m);
var s2 = MakeScore(2, 12.50m);
var s3 = MakeScore(3, 11.00m);
Setup(EventCategory.Field, new List<Score> { s1, s2, s3 });
await _service.CalculatePlacementsAsync(1);
Assert.Equal(1, s1.Placement);
Assert.Equal(1, s2.Placement);
Assert.Equal(9, s1.PlacementPoints);
Assert.Equal(9, s2.PlacementPoints);
Assert.Equal(3, s3.Placement);
Assert.Equal(6, s3.PlacementPoints);
}
[Fact]
public async Task CalculatePlacements_ZeroMark_GetsNoPlacementAndOldPlacementCleared()
{
var foul = MakeScore(1, 0m);
foul.Placement = 1; // stale from an earlier calculation
foul.PlacementPoints = 10;
var valid = MakeScore(2, 9.50m);
Setup(EventCategory.Field, new List<Score> { foul, valid });
await _service.CalculatePlacementsAsync(1);
Assert.Null(foul.Placement);
Assert.Equal(0, foul.PlacementPoints);
Assert.Equal(1, valid.Placement);
}
}
public class HeatAdvancementResetTests
{
[Fact]
public async Task CalculateAdvancement_ClearsStaleFlagsBeforeRecalculating()
{
// An athlete who advanced in a previous calculation but no longer qualifies
// after a time correction must lose the flag on recalculation.
var stale = new HeatLane { HeatLaneId = 1, Time = 12.5m, IsAdvanced = true, AdvanceReason = AdvanceReason.TopN };
var faster = new HeatLane { HeatLaneId = 2, Time = 11.0m };
var round = new Round
{
RoundId = 1,
AdvanceTopN = 1,
AdvanceFastestLosers = 0,
Heats = new List<Heat>
{
new() { HeatId = 1, HeatLanes = new List<HeatLane> { stale, faster } }
}
};
var mockUow = new Mock<IUnitOfWork>();
mockUow.Setup(u => u.Rounds.GetWithHeatsAsync(1)).ReturnsAsync(round);
mockUow.Setup(u => u.HeatLanes.Update(It.IsAny<HeatLane>()));
mockUow.Setup(u => u.SaveChangesAsync()).ReturnsAsync(1);
var service = new HeatManagementService(mockUow.Object, null!);
await service.CalculateAdvancementAsync(1);
Assert.False(stale.IsAdvanced);
Assert.Null(stale.AdvanceReason);
Assert.True(faster.IsAdvanced);
Assert.Equal(AdvanceReason.TopN, faster.AdvanceReason);
}
}

View File

@@ -61,7 +61,7 @@ public class StudentTests
DateOfBirth = new DateOnly(2012, 2, 29) // leap year DateOfBirth = new DateOnly(2012, 2, 29) // leap year
}; };
// March 1, 2025 - non-leap year, birthday hasn't technically passed // March 1, 2025 non-leap year; the Feb 29 birthday is treated as passed
var referenceDate = new DateOnly(2025, 3, 1); var referenceDate = new DateOnly(2025, 3, 1);
Assert.Equal(13, student.GetAge(referenceDate)); Assert.Equal(13, student.GetAge(referenceDate));
} }

File diff suppressed because it is too large Load Diff