diff --git a/Core/YearTransition/GraduatingGradeResolver.cs b/Core/YearTransition/GraduatingGradeResolver.cs new file mode 100644 index 0000000..4710e90 --- /dev/null +++ b/Core/YearTransition/GraduatingGradeResolver.cs @@ -0,0 +1,32 @@ +using Core.Models; + +namespace Core.YearTransition; + +/// +/// Resolves the graduating grade from chapter school level configuration. +/// +public static class GraduatingGradeResolver +{ + /// + /// Middle school students graduate after grade 8; high school after grade 12. + /// Returns null when school level is unset (both / unspecified). + /// + public static int? FromSchoolLevel(SchoolLevel? schoolLevel) => schoolLevel switch + { + SchoolLevel.MiddleSchool => 8, + SchoolLevel.HighSchool => 12, + _ => null + }; + + /// + /// Human-readable label for wizard display. + /// + public static string Describe(SchoolLevel schoolLevel, int graduatingGrade) => schoolLevel switch + { + SchoolLevel.MiddleSchool => + $"Middle school chapter — students graduate after grade {graduatingGrade}", + SchoolLevel.HighSchool => + $"High school chapter — students graduate after grade {graduatingGrade}", + _ => $"Students graduate after grade {graduatingGrade}" + }; +} diff --git a/Core/YearTransition/YearTransitionPlanner.cs b/Core/YearTransition/YearTransitionPlanner.cs new file mode 100644 index 0000000..a7ad58b --- /dev/null +++ b/Core/YearTransition/YearTransitionPlanner.cs @@ -0,0 +1,294 @@ +using Core.Entities; + +namespace Core.YearTransition; + +/// +/// Inputs for building a year-transition plan. +/// +public sealed class YearTransitionRequest +{ + public required IReadOnlyList Students { get; init; } + public required IReadOnlySet ReturningStudentIds { get; init; } + public IReadOnlyDictionary OfficerAssignments { get; init; } + = new Dictionary(); + public required int GraduatingGrade { get; init; } + public required string TargetCompetitionYear { get; init; } + public IReadOnlyList PastedNames { get; init; } = []; +} + +/// +/// Preview of a year transition before it is applied. +/// +public sealed class YearTransitionPlan +{ + public required string TargetCompetitionYear { get; init; } + public required int GraduatingGrade { get; init; } + public required IReadOnlyList Promotions { get; init; } + public required IReadOnlyList StudentsToRemove { get; init; } + public required IReadOnlyList OfficerChanges { get; init; } + public required IReadOnlyList UnmatchedPastedNames { get; init; } + public required IReadOnlyList AmbiguousPastedNames { get; init; } + public required IReadOnlyList Warnings { get; init; } + + public int ReturningCount => Promotions.Count; + public int RemovalCount => StudentsToRemove.Count; +} + +public sealed class StudentPromotion +{ + public required Student Student { get; init; } + public required int PreviousGrade { get; init; } + public required int NewGrade { get; init; } + public required int PreviousTsaYear { get; init; } + public required int NewTsaYear { get; init; } + public OfficerRole? PreviousOfficerRole { get; init; } + public OfficerRole? NewOfficerRole { get; init; } +} + +public sealed class OfficerAssignmentChange +{ + public required OfficerRole Role { get; init; } + public Student? NewOfficer { get; init; } + public Student? PreviousOfficer { get; init; } +} + +/// +/// Pure planner for year-end student promotion, graduation, and officer assignment. +/// +public static class YearTransitionPlanner +{ + private const int AbsoluteMaxGrade = 12; + + /// + /// Students at or above the graduating grade are suggested as non-returning. + /// + public static bool SuggestReturning(Student student, int graduatingGrade) => + student.Grade < graduatingGrade; + + /// + /// Parses pasted name lines and matches them to students. + /// + public static NameMatchResult MatchPastedNames( + IReadOnlyList students, + IEnumerable pastedLines) + { + var matchedIds = new HashSet(); + var unmatched = new List(); + var ambiguous = new List(); + + foreach (var rawLine in pastedLines) + { + var line = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(line)) + continue; + + var matches = FindNameMatches(students, line).ToList(); + if (matches.Count == 0) + { + unmatched.Add(line); + } + else if (matches.Count > 1) + { + ambiguous.Add(line); + foreach (var match in matches) + matchedIds.Add(match.Id); + } + else + { + matchedIds.Add(matches[0].Id); + } + } + + return new NameMatchResult(matchedIds, unmatched, ambiguous); + } + + public static YearTransitionPlan Build(YearTransitionRequest request) + { + ArgumentNullException.ThrowIfNull(request); + if (request.GraduatingGrade is < 5 or > AbsoluteMaxGrade) + throw new ArgumentOutOfRangeException(nameof(request), "Graduating grade must be between 5 and 12."); + + var studentsById = request.Students.ToDictionary(s => s.Id); + var warnings = new List(); + var promotions = new List(); + var toRemove = new List(); + + var nameMatch = MatchPastedNames(request.Students, request.PastedNames); + warnings.AddRange(nameMatch.AmbiguousNames.Select(n => + $"Pasted name '{n}' matched more than one student.")); + + // Officer role -> student id from request (null = vacant) + var newOfficerByRole = Enum.GetValues() + .ToDictionary( + role => role, + role => request.OfficerAssignments.TryGetValue(role, out var id) ? id : null); + + // Detect same student assigned to multiple offices + var assignedStudentIds = newOfficerByRole.Values + .Where(id => id.HasValue) + .Select(id => id!.Value) + .ToList(); + var duplicateOfficerStudents = assignedStudentIds + .GroupBy(id => id) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToHashSet(); + + foreach (var studentId in duplicateOfficerStudents) + { + if (studentsById.TryGetValue(studentId, out var student)) + { + warnings.Add($"{student.LastNameFirstName} is assigned to more than one officer role."); + } + } + + // Build new officer role lookup for returning students + var newRoleByStudentId = new Dictionary(); + foreach (var (role, studentId) in newOfficerByRole) + { + if (!studentId.HasValue) + continue; + + if (!request.ReturningStudentIds.Contains(studentId.Value)) + { + var name = studentsById.TryGetValue(studentId.Value, out var s) + ? s.LastNameFirstName + : $"Id {studentId.Value}"; + warnings.Add($"{role} is assigned to {name}, who is not marked returning."); + continue; + } + + if (duplicateOfficerStudents.Contains(studentId.Value)) + continue; + + newRoleByStudentId[studentId.Value] = role; + } + + foreach (var student in request.Students.OrderBy(s => s.LastName).ThenBy(s => s.FirstName)) + { + if (request.ReturningStudentIds.Contains(student.Id)) + { + var newGrade = Math.Min(AbsoluteMaxGrade, + Math.Min(request.GraduatingGrade, student.Grade + 1)); + var newTsaYear = student.TsaYear + 1; + + if (student.Grade >= request.GraduatingGrade) + { + warnings.Add( + $"{student.LastNameFirstName} is at or above graduating grade {request.GraduatingGrade} but marked returning; grade stays at {newGrade}."); + } + + OfficerRole? assignedRole = newRoleByStudentId.TryGetValue(student.Id, out var role) + ? role + : null; + + promotions.Add(new StudentPromotion + { + Student = student, + PreviousGrade = student.Grade, + NewGrade = newGrade, + PreviousTsaYear = student.TsaYear, + NewTsaYear = newTsaYear, + PreviousOfficerRole = student.OfficerRole, + NewOfficerRole = assignedRole + }); + } + else + { + toRemove.Add(student); + } + } + + var officerChanges = Enum.GetValues() + .Select(role => + { + var previous = request.Students.FirstOrDefault(s => s.OfficerRole == role); + Student? next = null; + if (newOfficerByRole.TryGetValue(role, out var nextId) && + nextId.HasValue && + studentsById.TryGetValue(nextId.Value, out var nextStudent) && + request.ReturningStudentIds.Contains(nextId.Value) && + !duplicateOfficerStudents.Contains(nextId.Value)) + { + next = nextStudent; + } + + return new OfficerAssignmentChange + { + Role = role, + PreviousOfficer = previous, + NewOfficer = next + }; + }) + .ToList(); + + return new YearTransitionPlan + { + TargetCompetitionYear = request.TargetCompetitionYear, + GraduatingGrade = request.GraduatingGrade, + Promotions = promotions, + StudentsToRemove = toRemove, + OfficerChanges = officerChanges, + UnmatchedPastedNames = nameMatch.UnmatchedNames, + AmbiguousPastedNames = nameMatch.AmbiguousNames, + Warnings = warnings + }; + } + + private static IEnumerable FindNameMatches(IReadOnlyList students, string line) + { + var comparer = StringComparer.OrdinalIgnoreCase; + + // Exact full-name matches first + var fullMatches = students.Where(s => + comparer.Equals(s.FirstNameLastName, line) || + comparer.Equals(s.LastNameFirstName, line)).ToList(); + if (fullMatches.Count > 0) + return fullMatches; + + var (first, last) = ParseName(line); + if (string.IsNullOrWhiteSpace(first) && string.IsNullOrWhiteSpace(last)) + return []; + + return students.Where(s => + comparer.Equals(s.FirstName.Trim(), first) && + comparer.Equals(s.LastName.Trim(), last)); + } + + /// + /// Parses a name line into (first, last), using for + /// "Last, First" and a last-space split for "First Last". + /// + private static (string First, string Last) ParseName(string fullName) + { + var trimmed = fullName.Trim(); + if (trimmed.Contains(',')) + { + var parts = Student.ParseNameParts(trimmed); + return (parts.Item1.Trim(), parts.Item2.Trim()); + } + + var lastSpace = trimmed.LastIndexOf(' '); + if (lastSpace <= 0) + return (trimmed, string.Empty); + + return (trimmed[..lastSpace].Trim(), trimmed[(lastSpace + 1)..].Trim()); + } +} + +public sealed class NameMatchResult +{ + public NameMatchResult( + IReadOnlySet matchedStudentIds, + IReadOnlyList unmatchedNames, + IReadOnlyList ambiguousNames) + { + MatchedStudentIds = matchedStudentIds; + UnmatchedNames = unmatchedNames; + AmbiguousNames = ambiguousNames; + } + + public IReadOnlySet MatchedStudentIds { get; } + public IReadOnlyList UnmatchedNames { get; } + public IReadOnlyList AmbiguousNames { get; } +} diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index e8b12f0..d24db05 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -172,6 +172,31 @@ docker-compose logs -f webapp --- +## Data Persistence (SQLite + Backups) + +The app stores its SQLite database and runtime config under **`Data/`** (capital D) relative to the content root: + +| Path in container | Purpose | +|-------------------|---------| +| `/app/Data/app.db` | Live SQLite database | +| `/app/Data/appsettings.json` | Chapter settings overrides (e.g. CompetitionYear) | +| `/app/Data/backups/pre-rollover-*.db` | Automatic backups created by New Year Rollover | + +**Docker volume mount must use capital `Data`:** + +```yaml +volumes: + - ./data:/app/Data +``` + +On Linux, `./data:/app/data` is a **different** path and will not persist the database or rollover backups. After a correct mount, host files appear under `./data/` (for example `./data/app.db` and `./data/backups/`). + +Ensure the container user can write to the mounted directory (the image runs as a non-root `APP_UID`). If directory creation for `backups/` fails, year rollover aborts before changing data. + +For the rollover workflow itself, see `docs/instructions/year-rollover.md`. + +--- + ## Managing Users ### Adding a New User @@ -310,10 +335,11 @@ curl http://localhost:8080 - [ ] Set file permissions to 600 - [ ] Configured HTTPS/SSL certificates - [ ] Updated `ASPNETCORE_URLS` for production domain -- [ ] Configured volume for database persistence +- [ ] Configured volume for database persistence as `./data:/app/Data` (capital D) +- [ ] Verified `app.db` appears on the host under `./data/` after first run - [ ] Removed development endpoints (already done in code) - [ ] Set up log monitoring -- [ ] Configured automatic backups +- [ ] Confirmed year-rollover backups write to `./data/backups/` (see `docs/instructions/year-rollover.md`) - [ ] Tested login with all user roles - [ ] Tested rate limiting (5 failed attempts) - [ ] Documented admin password securely diff --git a/Tests/YearTransition/YearTransitionPlanner_Tests.cs b/Tests/YearTransition/YearTransitionPlanner_Tests.cs new file mode 100644 index 0000000..6e40132 --- /dev/null +++ b/Tests/YearTransition/YearTransitionPlanner_Tests.cs @@ -0,0 +1,230 @@ +using Core.Entities; +using Core.Models; +using Core.YearTransition; +using NUnit.Framework; +using Tests.Builders; + +namespace Tests.YearTransition; + +[TestFixture] +public class GraduatingGradeResolver_Tests +{ + [Test] + public void FromSchoolLevel_MiddleSchool_Returns8() + { + Assert.That(GraduatingGradeResolver.FromSchoolLevel(SchoolLevel.MiddleSchool), Is.EqualTo(8)); + } + + [Test] + public void FromSchoolLevel_HighSchool_Returns12() + { + Assert.That(GraduatingGradeResolver.FromSchoolLevel(SchoolLevel.HighSchool), Is.EqualTo(12)); + } + + [Test] + public void FromSchoolLevel_Null_ReturnsNull() + { + Assert.That(GraduatingGradeResolver.FromSchoolLevel(null), Is.Null); + } +} + +[TestFixture] +public class YearTransitionPlanner_Tests +{ + [SetUp] + public void SetUp() + { + StudentBuilder.ResetIdCounter(); + } + + [Test] + public void SuggestReturning_BelowGraduatingGrade_IsTrue() + { + var student = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build(); + Assert.That(YearTransitionPlanner.SuggestReturning(student, graduatingGrade: 8), Is.True); + } + + [Test] + public void SuggestReturning_AtGraduatingGrade_IsFalse() + { + var student = StudentBuilder.Create("Ann", "Lee").WithGrade(8).Build(); + Assert.That(YearTransitionPlanner.SuggestReturning(student, graduatingGrade: 8), Is.False); + } + + [Test] + public void Build_PromotesReturningStudents_MiddleSchool() + { + var returning = StudentBuilder.Create("Ann", "Lee").WithGrade(6).WithTsaYear(1).Build(); + var graduating = StudentBuilder.Create("Bob", "Smith").WithGrade(8).WithTsaYear(3).Build(); + + var plan = YearTransitionPlanner.Build(new YearTransitionRequest + { + Students = [returning, graduating], + ReturningStudentIds = new HashSet { returning.Id }, + GraduatingGrade = 8, + TargetCompetitionYear = "2027" + }); + + Assert.That(plan.ReturningCount, Is.EqualTo(1)); + Assert.That(plan.RemovalCount, Is.EqualTo(1)); + Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(7)); + Assert.That(plan.Promotions[0].NewTsaYear, Is.EqualTo(2)); + Assert.That(plan.StudentsToRemove[0].Id, Is.EqualTo(graduating.Id)); + } + + [Test] + public void Build_CapsGradeAtGraduatingGrade_HighSchool() + { + var senior = StudentBuilder.Create("Chris", "Young").WithGrade(12).WithTsaYear(4).Build(); + + var plan = YearTransitionPlanner.Build(new YearTransitionRequest + { + Students = [senior], + ReturningStudentIds = new HashSet { senior.Id }, + GraduatingGrade = 12, + TargetCompetitionYear = "2027" + }); + + Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(12)); + Assert.That(plan.Promotions[0].NewTsaYear, Is.EqualTo(5)); + Assert.That(plan.Warnings, Has.Some.Contain("graduating grade")); + } + + [Test] + public void Build_CapsGradeAtEight_WhenMiddleSchoolReturnerAtGraduatingGrade() + { + var eighth = StudentBuilder.Create("Dana", "Nguyen").WithGrade(8).WithTsaYear(2).Build(); + + var plan = YearTransitionPlanner.Build(new YearTransitionRequest + { + Students = [eighth], + ReturningStudentIds = new HashSet { eighth.Id }, + GraduatingGrade = 8, + TargetCompetitionYear = "2027" + }); + + Assert.That(plan.Promotions[0].NewGrade, Is.EqualTo(8)); + Assert.That(plan.Warnings, Has.Some.Contain("graduating grade")); + } + + [Test] + public void Build_AssignsOfficersAndClearsOthers() + { + var president = StudentBuilder.Create("Eve", "Adams").WithGrade(7).AsPresident().Build(); + var vp = StudentBuilder.Create("Frank", "Baker").WithGrade(6).Build(); + + var plan = YearTransitionPlanner.Build(new YearTransitionRequest + { + Students = [president, vp], + ReturningStudentIds = new HashSet { president.Id, vp.Id }, + GraduatingGrade = 8, + TargetCompetitionYear = "2027", + OfficerAssignments = new Dictionary + { + [OfficerRole.President] = vp.Id, + [OfficerRole.VicePresident] = president.Id + } + }); + + var eve = plan.Promotions.Single(p => p.Student.Id == president.Id); + var frank = plan.Promotions.Single(p => p.Student.Id == vp.Id); + + Assert.That(eve.PreviousOfficerRole, Is.EqualTo(OfficerRole.President)); + Assert.That(eve.NewOfficerRole, Is.EqualTo(OfficerRole.VicePresident)); + Assert.That(frank.NewOfficerRole, Is.EqualTo(OfficerRole.President)); + + var presidentChange = plan.OfficerChanges.Single(c => c.Role == OfficerRole.President); + Assert.That(presidentChange.PreviousOfficer!.Id, Is.EqualTo(president.Id)); + Assert.That(presidentChange.NewOfficer!.Id, Is.EqualTo(vp.Id)); + } + + [Test] + public void Build_WarnsWhenOfficerAssignedToNonReturningStudent() + { + var returning = StudentBuilder.Create("Gina", "Cole").WithGrade(6).Build(); + var leaving = StudentBuilder.Create("Hank", "Diaz").WithGrade(8).Build(); + + var plan = YearTransitionPlanner.Build(new YearTransitionRequest + { + Students = [returning, leaving], + ReturningStudentIds = new HashSet { returning.Id }, + GraduatingGrade = 8, + TargetCompetitionYear = "2027", + OfficerAssignments = new Dictionary + { + [OfficerRole.President] = leaving.Id + } + }); + + Assert.That(plan.Warnings, Has.Some.Contain("not marked returning")); + Assert.That(plan.Promotions[0].NewOfficerRole, Is.Null); + } + + [Test] + public void Build_WarnsWhenSameStudentHasTwoOffices() + { + var student = StudentBuilder.Create("Ivy", "Evans").WithGrade(7).Build(); + + var plan = YearTransitionPlanner.Build(new YearTransitionRequest + { + Students = [student], + ReturningStudentIds = new HashSet { student.Id }, + GraduatingGrade = 8, + TargetCompetitionYear = "2027", + OfficerAssignments = new Dictionary + { + [OfficerRole.President] = student.Id, + [OfficerRole.Treasurer] = student.Id + } + }); + + Assert.That(plan.Warnings, Has.Some.Contain("more than one officer role")); + Assert.That(plan.Promotions[0].NewOfficerRole, Is.Null); + } + + [Test] + public void MatchPastedNames_MatchesLastCommaFirstAndFirstLast() + { + var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build(); + var b = StudentBuilder.Create("Bob", "Smith").WithGrade(7).Build(); + + var result = YearTransitionPlanner.MatchPastedNames( + [a, b], + ["Lee, Ann", "Bob Smith", "Nobody Here"]); + + Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { a.Id, b.Id })); + Assert.That(result.UnmatchedNames, Is.EquivalentTo(new[] { "Nobody Here" })); + } + + [Test] + public void MatchPastedNames_AmbiguousWhenDuplicateNames() + { + var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build(); + var b = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build(); + + var result = YearTransitionPlanner.MatchPastedNames([a, b], ["Ann Lee"]); + + Assert.That(result.AmbiguousNames, Is.EquivalentTo(new[] { "Ann Lee" })); + Assert.That(result.MatchedStudentIds, Is.EquivalentTo(new[] { a.Id, b.Id })); + } + + [Test] + public void Build_IncludesUnmatchedAndAmbiguousFromPaste() + { + var a = StudentBuilder.Create("Ann", "Lee").WithGrade(6).Build(); + var b = StudentBuilder.Create("Ann", "Lee").WithGrade(7).Build(); + + var plan = YearTransitionPlanner.Build(new YearTransitionRequest + { + Students = [a, b], + ReturningStudentIds = new HashSet { a.Id }, + GraduatingGrade = 8, + TargetCompetitionYear = "2027", + PastedNames = ["Ann Lee", "Zed Zulu"] + }); + + Assert.That(plan.AmbiguousPastedNames, Has.Member("Ann Lee")); + Assert.That(plan.UnmatchedPastedNames, Has.Member("Zed Zulu")); + Assert.That(plan.Warnings, Has.Some.Contain("matched more than one")); + } +} diff --git a/WebApp/Components/Pages/ChapterSettings.razor b/WebApp/Components/Pages/ChapterSettings.razor index b032e85..7bb82dc 100644 --- a/WebApp/Components/Pages/ChapterSettings.razor +++ b/WebApp/Components/Pages/ChapterSettings.razor @@ -3,10 +3,10 @@ @using WebApp.Authentication @using WebApp.Models @using WebApp.Components.Shared.Components -@using System.Text.Json +@using WebApp.Services @using Core.Models -@inject IWebHostEnvironment Environment @inject IConfiguration Configuration +@inject IChapterSettingsWriter ChapterSettingsWriter @rendermode InteractiveServer @@ -125,19 +125,10 @@ protected override void OnInitialized() { - // Load from IConfiguration _settings = Configuration.GetSection("ChapterSettings").Get() ?? new Models.ChapterSettings(); } - private string GetAppSettingsPath() - { - return Path.Combine( - Environment.ContentRootPath, - "Data", - "appsettings.json"); - } - private async Task SaveSettings() { if (_settings == null) return; @@ -147,41 +138,7 @@ try { - var appSettingsPath = GetAppSettingsPath(); - - // Ensure Data directory exists - var dataDir = Path.GetDirectoryName(appSettingsPath); - if (dataDir != null && !Directory.Exists(dataDir)) - { - Directory.CreateDirectory(dataDir); - } - - // Read existing appsettings or create new - JsonDocument? existingDoc = null; - Dictionary settings; - - if (File.Exists(appSettingsPath)) - { - var existingJson = await File.ReadAllTextAsync(appSettingsPath); - existingDoc = JsonDocument.Parse(existingJson); - settings = JsonSerializer.Deserialize>(existingJson) - ?? new Dictionary(); - } - else - { - settings = new Dictionary(); - } - - // Update ChapterSettings section - settings["ChapterSettings"] = _settings; - - // Write back to file - var options = new JsonSerializerOptions { WriteIndented = true }; - var json = JsonSerializer.Serialize(settings, options); - await File.WriteAllTextAsync(appSettingsPath, json); - - existingDoc?.Dispose(); - + await ChapterSettingsWriter.WriteAsync(_settings); _statusMessage = "Settings saved successfully! Changes will take effect on next application restart."; _statusSeverity = Severity.Success; } diff --git a/WebApp/Components/Pages/YearRollover.razor b/WebApp/Components/Pages/YearRollover.razor new file mode 100644 index 0000000..8c22a50 --- /dev/null +++ b/WebApp/Components/Pages/YearRollover.razor @@ -0,0 +1,651 @@ +@page "/settings/new-year" +@attribute [Authorize(Roles = AuthRoles.Administrator)] +@implements IAsyncDisposable +@using Core.Entities +@using Core.Models +@using Core.YearTransition +@using Data +@using Microsoft.EntityFrameworkCore +@using WebApp.Authentication +@using WebApp.Components.Shared.Components +@using WebApp.Services +@inject AppDbContext Context +@inject IConfiguration Configuration +@inject IYearRolloverService YearRolloverService +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject ILogger Logger + +@rendermode InteractiveServer + + + + + @if (_result != null) + { + + Rollover to @_result.CompetitionYear completed successfully. + + + Summary + Backup: @_result.BackupPath + Students promoted: @_result.StudentsPromoted + Students removed: @_result.StudentsRemoved + Teams deleted: @_result.TeamsDeleted + Event rankings deleted: @_result.RankingsDeleted + Meeting histories deleted: @_result.MeetingHistoriesDeleted + Event occurrences deleted: @_result.EventOccurrencesDeleted + Officers + + @foreach (var line in _result.OfficerSummary) + { + @line + } + + + Restart the application so printouts and the home page show the new competition year. + Then add new students, import the new state schedule, and clear Meeting Schedule browser state with Reset. + + + } + else if (_students == null) + { + + } + else if (!_wizardUnlocked) + { + + + This wizard permanently changes production chapter data. It is locked until you intentionally unlock it. + + + Before you continue + + Unlocking lets you plan a rollover. Applying it will still require a second typed confirmation. + An automatic database backup is created immediately before apply and is the only undo. + + + + + Non-returning students are permanently deleted + + + All teams, event rankings, and meeting history are cleared + + + Event occurrences are cleared by default (state schedule) + + + + + + + + + + Unlock wizard + + + } + else + { + + Wizard unlocked for this session. Close or refresh this page to lock it again. + + Lock again + + + + + Year & grades + Returning roster + Officers + Season reset + Preview & apply + + + @if (Step == 0) + { + + Competition year + + + Chapter type + @if (_configuredSchoolLevel is { } configured) + { + + @GraduatingGradeResolver.Describe(configured, _graduatingGrade!.Value) + Change in Chapter Settings + + } + else + { + + School level is not set in Chapter Settings (Both MS and HS). Choose the chapter type for this rollover, + then set it permanently on the Chapter Settings page. + + + Middle School (graduate after grade 8) + High School (graduate after grade 12) + + } + + + Applying the rollover will create an automatic backup at + Data/backups/pre-rollover-*.db before making any changes. + That backup is the only undo. + + + } + else if (Step == 1) + { + + Returning students + + Students at or above graduating grade @_graduatingGrade are unchecked by default. + Paste a list of names (one per line) to check matches. + + + + + Apply pasted names + + + @if (_pasteUnmatched.Count > 0) + { + + Unmatched: @string.Join("; ", _pasteUnmatched) + + } + @if (_pasteAmbiguous.Count > 0) + { + + Ambiguous (check manually): @string.Join("; ", _pasteAmbiguous) + + } + + + + Returning + Name + Grade + TSA Year + Officer + + + + + + @context.LastNameFirstName + @context.Grade + @context.TsaYear + @(context.OfficerRole?.ToString() ?? "—") + + + + @_returningIds.Count returning · @(_students.Count - _returningIds.Count) will be removed + + + } + else if (Step == 2) + { + + New officer slate + + Leave a role blank to leave that office vacant. Only returning students are listed. + New students who will be officers can be assigned later on the student edit page. + + + @foreach (var role in _officerRoles) + { + + + @foreach (var student in ReturningStudents) + { + @student.LastNameFirstName + } + + + } + + + } + else if (Step == 3) + { + + Season reset + + The following are always cleared: all teams, all event rankings, and all meeting history records. + Written notes on the Notes page are not affected. + + + + Leave this checked unless you plan to keep last year's calendar rows. Import the new schedule afterward. + + + } + else if (Step == 4) + { + var plan = BuildPlan(); + + Preview + Competition year → @plan.TargetCompetitionYear + Promote @plan.ReturningCount students · Remove @plan.RemovalCount students + Clear teams, rankings, meeting history@( _clearEventOccurrences ? ", and event occurrences" : "" ) + + @if (plan.Warnings.Count > 0) + { + + Warnings +
    + @foreach (var warning in plan.Warnings) + { +
  • @warning
  • + } +
+
+ } + + + + + + Name + Grade + TSA Year + Officer + + + @context.Student.LastNameFirstName + @context.PreviousGrade → @context.NewGrade + @context.PreviousTsaYear → @context.NewTsaYear + @(context.PreviousOfficerRole?.ToString() ?? "—") → @(context.NewOfficerRole?.ToString() ?? "—") + + + + + + @foreach (var student in plan.StudentsToRemove) + { + @student.LastNameFirstName (grade @student.Grade) + } + + + + + @foreach (var change in plan.OfficerChanges) + { + + @change.Role: + @(change.NewOfficer?.LastNameFirstName ?? "(vacant)") + @if (change.PreviousOfficer != null) + { + (was @change.PreviousOfficer.LastNameFirstName) + } + + } + + + + + + + + @if (_isApplying) + { + + Applying... + } + else + { + Apply rollover + } + +
+ } + + + + Back + + @if (Step < 4) + { + + Next + + } + + } +
+ +@code { + private const string UnlockPhrase = "ROLLOVER"; + + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed; + private bool _wizardUnlocked; + private bool _ackDestructive; + private bool _ackBackupOnlyUndo; + private string _unlockPhrase = ""; + private string _applyConfirmYear = ""; + private int _step; + private int Step + { + get => _step; + set + { + _step = value; + if (_step >= 1) + EnsureReturningDefaults(); + if (_step >= 2) + PruneOfficerSelections(); + } + } + private List? _students; + private HashSet _returningIds = []; + private Dictionary _officerSelections = []; + private readonly OfficerRole[] _officerRoles = Enum.GetValues(); + + private string _targetYear = "2027"; + private SchoolLevel? _configuredSchoolLevel; + private SchoolLevel? _overrideSchoolLevel; + private int? _graduatingGrade; + private string _pasteBox = ""; + private List _pasteUnmatched = []; + private List _pasteAmbiguous = []; + private bool _clearEventOccurrences = true; + private bool _isApplying; + private YearRolloverResult? _result; + private bool _returningInitialized; + + private IEnumerable ReturningStudents => + _students?.Where(s => _returningIds.Contains(s.Id)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName) + ?? Enumerable.Empty(); + + private bool CanUnlockWizard => + _ackDestructive && + _ackBackupOnlyUndo && + string.Equals(_unlockPhrase.Trim(), UnlockPhrase, StringComparison.OrdinalIgnoreCase); + + private bool CanApply => + _wizardUnlocked && + _graduatingGrade.HasValue && + !string.IsNullOrWhiteSpace(_targetYear) && + string.Equals(_applyConfirmYear.Trim(), _targetYear.Trim(), StringComparison.Ordinal); + + private bool CanGoNext => Step switch + { + 0 => _graduatingGrade.HasValue && !string.IsNullOrWhiteSpace(_targetYear), + _ => true + }; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + + var currentYear = Configuration["ChapterSettings:CompetitionYear"] ?? "2026"; + if (int.TryParse(currentYear, out var year)) + _targetYear = (year + 1).ToString(); + else + _targetYear = currentYear; + + _configuredSchoolLevel = Configuration.GetSection("ChapterSettings").Get()?.SchoolLevel + ?? ParseSchoolLevel(Configuration["ChapterSettings:SchoolLevel"]); + _graduatingGrade = GraduatingGradeResolver.FromSchoolLevel(_configuredSchoolLevel); + } + + protected override async Task OnInitializedAsync() + { + try + { + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + _students = await Context.Students + .AsNoTracking() + .OrderBy(s => s.LastName) + .ThenBy(s => s.FirstName) + .ToListAsync(token); + + foreach (var role in _officerRoles) + _officerSelections[role] = null; + } + catch (TaskCanceledException) + { + // disposed + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load students for year rollover"); + if (!_isDisposed) + Snackbar.Add($"Failed to load students: {ex.Message}", Severity.Error); + } + } + + private static SchoolLevel? ParseSchoolLevel(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + return Enum.TryParse(value, ignoreCase: true, out var parsed) ? parsed : null; + } + + private void UnlockWizard() + { + if (!CanUnlockWizard) + return; + + _wizardUnlocked = true; + Step = 0; + Snackbar.Add("Year rollover wizard unlocked for this session", Severity.Warning); + } + + private void LockWizard() + { + _wizardUnlocked = false; + _ackDestructive = false; + _ackBackupOnlyUndo = false; + _unlockPhrase = ""; + _applyConfirmYear = ""; + Step = 0; + } + + private void OnOverrideSchoolLevelChanged(SchoolLevel? value) + { + _overrideSchoolLevel = value; + _graduatingGrade = GraduatingGradeResolver.FromSchoolLevel(value); + _returningInitialized = false; + } + + private void EnsureReturningDefaults() + { + if (_returningInitialized || _students == null || !_graduatingGrade.HasValue) + return; + + _returningIds = _students + .Where(s => YearTransitionPlanner.SuggestReturning(s, _graduatingGrade.Value)) + .Select(s => s.Id) + .ToHashSet(); + _returningInitialized = true; + } + + private void GoNext() + { + Step++; + } + + private void SetReturning(int studentId, bool returning) + { + if (returning) + _returningIds.Add(studentId); + else + { + _returningIds.Remove(studentId); + PruneOfficerSelections(); + } + } + + private void ApplyPastedNames() + { + if (_students == null) + return; + + var lines = _pasteBox.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var result = YearTransitionPlanner.MatchPastedNames(_students, lines); + foreach (var id in result.MatchedStudentIds) + _returningIds.Add(id); + _pasteUnmatched = result.UnmatchedNames.ToList(); + _pasteAmbiguous = result.AmbiguousNames.ToList(); + } + + private int? GetOfficerSelection(OfficerRole role) => + _officerSelections.TryGetValue(role, out var id) ? id : null; + + private void SetOfficerSelection(OfficerRole role, int? studentId) + { + _officerSelections[role] = studentId; + } + + private void PruneOfficerSelections() + { + foreach (var role in _officerRoles) + { + if (_officerSelections.TryGetValue(role, out var id) && + id.HasValue && + !_returningIds.Contains(id.Value)) + { + _officerSelections[role] = null; + } + } + } + + private YearTransitionPlan BuildPlan() + { + return YearTransitionPlanner.Build(new YearTransitionRequest + { + Students = _students ?? [], + ReturningStudentIds = _returningIds, + OfficerAssignments = _officerSelections, + GraduatingGrade = _graduatingGrade ?? 8, + TargetCompetitionYear = _targetYear.Trim(), + PastedNames = [] + }); + } + + private async Task ConfirmAndApply() + { + if (_isDisposed || !CanApply || !_wizardUnlocked) + return; + + if (!string.Equals(_applyConfirmYear.Trim(), _targetYear.Trim(), StringComparison.Ordinal)) + { + Snackbar.Add("Type the target competition year exactly to confirm.", Severity.Warning); + return; + } + + var plan = BuildPlan(); + var message = + $"This will permanently delete {plan.RemovalCount} student(s), all teams, all event rankings, " + + $"all meeting history{(_clearEventOccurrences ? ", and all event occurrences" : "")}. " + + $"An automatic database backup will be created first and is the only undo. Continue?"; + + var confirmed = await DialogService.ShowMessageBox( + "Confirm year rollover", + message, + yesText: "Yes, apply rollover", + cancelText: "Cancel"); + + if (confirmed != true || _isDisposed || !_wizardUnlocked) + return; + + _isApplying = true; + try + { + var token = _cancellationTokenSource?.Token ?? CancellationToken.None; + _result = await YearRolloverService.ApplyAsync(new YearRolloverOptions + { + Plan = plan, + ClearEventOccurrences = _clearEventOccurrences + }, token); + + if (!_isDisposed) + Snackbar.Add($"Rollover to {_result.CompetitionYear} complete", Severity.Success); + } + catch (TaskCanceledException) + { + // disposed + } + catch (JSDisconnectedException) + { + // connection lost + } + catch (Exception ex) + { + Logger.LogError(ex, "Year rollover apply failed"); + if (!_isDisposed) + Snackbar.Add($"Rollover failed: {ex.Message}", Severity.Error); + } + finally + { + _isApplying = false; + } + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Shared/Layout/NavMenu.razor b/WebApp/Components/Shared/Layout/NavMenu.razor index 809f890..b7474be 100644 --- a/WebApp/Components/Shared/Layout/NavMenu.razor +++ b/WebApp/Components/Shared/Layout/NavMenu.razor @@ -35,6 +35,7 @@ Chapter Settings + New Year Rollover (locked) Validation Settings diff --git a/WebApp/Program.cs b/WebApp/Program.cs index c9626f4..531770b 100644 --- a/WebApp/Program.cs +++ b/WebApp/Program.cs @@ -201,6 +201,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.Configure( builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName)); diff --git a/WebApp/Services/ChapterSettingsWriter.cs b/WebApp/Services/ChapterSettingsWriter.cs new file mode 100644 index 0000000..1781e5c --- /dev/null +++ b/WebApp/Services/ChapterSettingsWriter.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using WebApp.Models; + +namespace WebApp.Services; + +/// +/// Persists chapter settings to Data/appsettings.json. +/// +public class ChapterSettingsWriter : IChapterSettingsWriter +{ + private readonly IWebHostEnvironment _environment; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public ChapterSettingsWriter( + IWebHostEnvironment environment, + IConfiguration configuration, + ILogger logger) + { + _environment = environment; + _configuration = configuration; + _logger = logger; + } + + public async Task WriteAsync(ChapterSettings settings, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(settings); + + var appSettingsPath = GetAppSettingsPath(); + var dataDir = Path.GetDirectoryName(appSettingsPath); + if (dataDir != null && !Directory.Exists(dataDir)) + { + Directory.CreateDirectory(dataDir); + } + + Dictionary root; + if (File.Exists(appSettingsPath)) + { + var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken); + root = JsonSerializer.Deserialize>(existingJson) + ?? []; + } + else + { + root = []; + } + + root["ChapterSettings"] = settings; + + var options = new JsonSerializerOptions { WriteIndented = true }; + var json = JsonSerializer.Serialize(root, options); + await File.WriteAllTextAsync(appSettingsPath, json, cancellationToken); + + _logger.LogInformation("Chapter settings written to {Path}", appSettingsPath); + } + + public async Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default) + { + var settings = _configuration.GetSection("ChapterSettings").Get() + ?? new ChapterSettings(); + settings.CompetitionYear = competitionYear; + await WriteAsync(settings, cancellationToken); + } + + private string GetAppSettingsPath() => + Path.Combine(_environment.ContentRootPath, "Data", "appsettings.json"); +} diff --git a/WebApp/Services/DatabaseBackupService.cs b/WebApp/Services/DatabaseBackupService.cs new file mode 100644 index 0000000..d8df90f --- /dev/null +++ b/WebApp/Services/DatabaseBackupService.cs @@ -0,0 +1,48 @@ +using Data; +using Microsoft.EntityFrameworkCore; + +namespace WebApp.Services; + +/// +/// Creates SQLite database backups via VACUUM INTO. +/// +public class DatabaseBackupService : IDatabaseBackupService +{ + private readonly AppDbContext _context; + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + public DatabaseBackupService( + AppDbContext context, + IWebHostEnvironment environment, + ILogger logger) + { + _context = context; + _environment = environment; + _logger = logger; + } + + public async Task CreatePreRolloverBackupAsync(CancellationToken cancellationToken = default) + { + var backupsDir = Path.Combine(_environment.ContentRootPath, "Data", "backups"); + Directory.CreateDirectory(backupsDir); + + var fileName = $"pre-rollover-{DateTime.Now:yyyyMMdd-HHmmss}.db"; + var backupPath = Path.Combine(backupsDir, fileName); + + // Path is server-generated (never user input); escape single quotes for SQLite string literal. + var escapedPath = backupPath.Replace("'", "''", StringComparison.Ordinal); +#pragma warning disable EF1002 // Path is fully server-controlled; VACUUM INTO cannot use parameters. + await _context.Database.ExecuteSqlRawAsync($"VACUUM INTO '{escapedPath}'", cancellationToken); +#pragma warning restore EF1002 + + if (!File.Exists(backupPath)) + { + throw new InvalidOperationException( + $"Database backup was requested but the file was not created at '{backupPath}'."); + } + + _logger.LogInformation("Created pre-rollover database backup at {BackupPath}", backupPath); + return backupPath; + } +} diff --git a/WebApp/Services/IChapterSettingsWriter.cs b/WebApp/Services/IChapterSettingsWriter.cs new file mode 100644 index 0000000..dd10c70 --- /dev/null +++ b/WebApp/Services/IChapterSettingsWriter.cs @@ -0,0 +1,19 @@ +using WebApp.Models; + +namespace WebApp.Services; + +/// +/// Persists chapter settings to Data/appsettings.json. +/// +public interface IChapterSettingsWriter +{ + /// + /// Writes the given chapter settings, preserving other top-level sections in the file. + /// + Task WriteAsync(ChapterSettings settings, CancellationToken cancellationToken = default); + + /// + /// Updates only the competition year while preserving other chapter settings from configuration. + /// + Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default); +} diff --git a/WebApp/Services/IDatabaseBackupService.cs b/WebApp/Services/IDatabaseBackupService.cs new file mode 100644 index 0000000..1c6dcd9 --- /dev/null +++ b/WebApp/Services/IDatabaseBackupService.cs @@ -0,0 +1,13 @@ +namespace WebApp.Services; + +/// +/// Creates SQLite database backups. +/// +public interface IDatabaseBackupService +{ + /// + /// Creates a pre-rollover backup of the application database using SQLite VACUUM INTO. + /// + /// The absolute path of the backup file. + Task CreatePreRolloverBackupAsync(CancellationToken cancellationToken = default); +} diff --git a/WebApp/Services/IYearRolloverService.cs b/WebApp/Services/IYearRolloverService.cs new file mode 100644 index 0000000..40a7062 --- /dev/null +++ b/WebApp/Services/IYearRolloverService.cs @@ -0,0 +1,36 @@ +using Core.YearTransition; + +namespace WebApp.Services; + +/// +/// Options for applying a year rollover. +/// +public sealed class YearRolloverOptions +{ + public required YearTransitionPlan Plan { get; init; } + public bool ClearEventOccurrences { get; init; } = true; +} + +/// +/// Result of a successful year rollover. +/// +public sealed class YearRolloverResult +{ + public required string BackupPath { get; init; } + public required int StudentsRemoved { get; init; } + public required int StudentsPromoted { get; init; } + public required int TeamsDeleted { get; init; } + public required int RankingsDeleted { get; init; } + public required int MeetingHistoriesDeleted { get; init; } + public required int EventOccurrencesDeleted { get; init; } + public required string CompetitionYear { get; init; } + public required IReadOnlyList OfficerSummary { get; init; } +} + +/// +/// Applies a year-transition plan to the database. +/// +public interface IYearRolloverService +{ + Task ApplyAsync(YearRolloverOptions options, CancellationToken cancellationToken = default); +} diff --git a/WebApp/Services/YearRolloverService.cs b/WebApp/Services/YearRolloverService.cs new file mode 100644 index 0000000..9f3a137 --- /dev/null +++ b/WebApp/Services/YearRolloverService.cs @@ -0,0 +1,182 @@ +using Core.YearTransition; +using Data; +using Microsoft.EntityFrameworkCore; + +namespace WebApp.Services; + +/// +/// Applies a year-transition plan: backup, wipe season data, promote/remove students, update year. +/// +public class YearRolloverService : IYearRolloverService +{ + private readonly AppDbContext _context; + private readonly IDatabaseBackupService _backupService; + private readonly IChapterSettingsWriter _chapterSettingsWriter; + private readonly ILogger _logger; + + public YearRolloverService( + AppDbContext context, + IDatabaseBackupService backupService, + IChapterSettingsWriter chapterSettingsWriter, + ILogger logger) + { + _context = context; + _backupService = backupService; + _chapterSettingsWriter = chapterSettingsWriter; + _logger = logger; + } + + public async Task ApplyAsync( + YearRolloverOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(options.Plan); + + var plan = options.Plan; + var returningIds = plan.Promotions.Select(p => p.Student.Id).ToHashSet(); + var removalIds = plan.StudentsToRemove.Select(s => s.Id).ToHashSet(); + + _logger.LogInformation( + "Starting year rollover to {Year}: {Returning} returning, {Removing} removing, clearOccurrences={ClearOccurrences}", + plan.TargetCompetitionYear, + plan.ReturningCount, + plan.RemovalCount, + options.ClearEventOccurrences); + + // VACUUM INTO cannot run inside a transaction — backup first and abort if it fails. + var backupPath = await _backupService.CreatePreRolloverBackupAsync(cancellationToken); + + int meetingHistoriesDeleted; + int teamsDeleted; + int rankingsDeleted; + int eventOccurrencesDeleted; + int studentsRemoved; + int studentsPromoted; + List officerSummary; + + await using (var transaction = await _context.Database.BeginTransactionAsync(cancellationToken)) + { + try + { + // Delete season data with ExecuteDelete / SQL so we never leave tracked Team + // entities in the change tracker (Include+Remove then ExecuteDelete caused + // optimistic concurrency failures when later deleting captain students). + meetingHistoriesDeleted = await _context.TeamMeetingHistories.CountAsync(cancellationToken); + await _context.Database.ExecuteSqlRawAsync( + """DELETE FROM "TeamMeetingHistoryTeams" """, cancellationToken); + await _context.Database.ExecuteSqlRawAsync( + """DELETE FROM "TeamMeetingHistoryStudents" """, cancellationToken); + await _context.TeamMeetingHistories.ExecuteDeleteAsync(cancellationToken); + + teamsDeleted = await _context.Teams.ExecuteDeleteAsync(cancellationToken); + rankingsDeleted = await _context.StudentEventRanking.ExecuteDeleteAsync(cancellationToken); + + eventOccurrencesDeleted = 0; + if (options.ClearEventOccurrences) + { + eventOccurrencesDeleted = await _context.EventOccurrences.ExecuteDeleteAsync(cancellationToken); + } + + // Drop any stale tracked entities from earlier queries in this request scope. + _context.ChangeTracker.Clear(); + + var students = await _context.Students.ToListAsync(cancellationToken); + var toRemove = students.Where(s => removalIds.Contains(s.Id)).ToList(); + var toPromote = students.Where(s => returningIds.Contains(s.Id)).ToList(); + + if (toRemove.Count != removalIds.Count) + { + throw new InvalidOperationException( + "Some students marked for removal were not found in the database. Aborting rollover."); + } + + if (toPromote.Count != returningIds.Count) + { + throw new InvalidOperationException( + "Some returning students were not found in the database. Aborting rollover."); + } + + // Extra students added while the wizard was open — refuse rather than leave them unprocessed. + var plannedIds = returningIds.Union(removalIds).ToHashSet(); + var unexpected = students.Where(s => !plannedIds.Contains(s.Id)).ToList(); + if (unexpected.Count > 0) + { + var names = string.Join(", ", unexpected.Select(s => s.LastNameFirstName)); + throw new InvalidOperationException( + $"Roster changed since the wizard loaded ({unexpected.Count} unexpected student(s): {names}). Reload the page and try again."); + } + + _context.Students.RemoveRange(toRemove); + + var promotionById = plan.Promotions.ToDictionary(p => p.Student.Id); + foreach (var student in toPromote) + { + var promotion = promotionById[student.Id]; + student.Grade = promotion.NewGrade; + student.TsaYear = promotion.NewTsaYear; + student.OfficerRole = promotion.NewOfficerRole; + } + + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + studentsRemoved = toRemove.Count; + studentsPromoted = toPromote.Count; + officerSummary = plan.OfficerChanges + .Select(c => c.NewOfficer == null + ? $"{c.Role}: (vacant)" + : $"{c.Role}: {c.NewOfficer.LastNameFirstName}") + .ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Year rollover failed after backup at {BackupPath}; rolling back database changes", backupPath); + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + try + { + await _chapterSettingsWriter.UpdateCompetitionYearAsync( + plan.TargetCompetitionYear, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, + "Year rollover DB changes committed but CompetitionYear file update failed. Backup={BackupPath}", + backupPath); + throw new InvalidOperationException( + $"Database rollover succeeded (backup at '{backupPath}'), but updating CompetitionYear failed: {ex.Message}. Set the year in Chapter Settings, then restart.", + ex); + } + + var result = new YearRolloverResult + { + BackupPath = backupPath, + StudentsRemoved = studentsRemoved, + StudentsPromoted = studentsPromoted, + TeamsDeleted = teamsDeleted, + RankingsDeleted = rankingsDeleted, + MeetingHistoriesDeleted = meetingHistoriesDeleted, + EventOccurrencesDeleted = eventOccurrencesDeleted, + CompetitionYear = plan.TargetCompetitionYear, + OfficerSummary = officerSummary + }; + + _logger.LogInformation( + "Year rollover complete. Backup={BackupPath}, Removed={Removed}, Promoted={Promoted}, Teams={Teams}, Rankings={Rankings}, Histories={Histories}, Occurrences={Occurrences}, Officers={Officers}", + result.BackupPath, + result.StudentsRemoved, + result.StudentsPromoted, + result.TeamsDeleted, + result.RankingsDeleted, + result.MeetingHistoriesDeleted, + result.EventOccurrencesDeleted, + string.Join("; ", officerSummary)); + + return result; + } +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 49469c3..34e9355 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -26,8 +26,9 @@ services: # Option 1: Volume-mounted secrets file (recommended for easy editing) - ./auth-secrets.json:/app/secrets/auth-secrets.json:ro - # Database persistence - - ./data:/app/data + # Database persistence — must use capital D (/app/Data). Linux paths are + # case-sensitive; ./data:/app/data will NOT persist app.db or rollover backups. + - ./data:/app/Data # HTTPS certificate (if needed) # - ./certs:/https:ro diff --git a/docs/instructions/year-rollover.md b/docs/instructions/year-rollover.md new file mode 100644 index 0000000..59583eb --- /dev/null +++ b/docs/instructions/year-rollover.md @@ -0,0 +1,67 @@ +# Year Rollover Runbook + +**Created:** 2026-08-14 +**Last updated:** 2026-08-14 +**Description:** How to roll the chapter into a new competition year using the locked New Year wizard. + +## Prerequisites + +1. Confirm **School Level** is set correctly on [Chapter Settings](/settings/chapter) (`Middle School` or `High School`). That drives the graduating grade (8 or 12). +2. Have the list of **returning students** and the **new officer slate** ready. +3. Plan a short maintenance window: after apply you must **restart the app** so printouts pick up the new competition year. +4. On Docker, confirm the data volume is `./data:/app/Data` (capital **D**). See `DEPLOYMENT.md` — otherwise the automatic backup may not land on the host. + +## Steps + +1. Sign in as an **Administrator**. +2. Open **New Year Rollover** at `/settings/new-year` (Admin nav: “New Year Rollover (locked)”). +3. **Unlock the wizard** (required every session): + - Check both acknowledgment boxes. + - Type `ROLLOVER` and click **Unlock wizard**. + - Refreshing or using **Lock again** re-locks it. +4. **Step 1 — Year & grades** + - Confirm the target competition year (defaults to current + 1). + - Confirm the chapter type / graduating grade shown from Chapter Settings. +5. **Step 2 — Returning roster** + - Uncheck anyone who is not returning (grade at/above graduating is unchecked by default). + - Optionally paste a name list (`Last, First` or `First Last`) and click **Apply pasted names**. +6. **Step 3 — Officers** + - Assign each office from returning students, or leave vacant. + - Officers who are brand-new students can be set later on the student edit page. +7. **Step 4 — Season reset** + - Teams, event rankings, and meeting history are always cleared. + - Leave **Clear all event occurrences** checked unless you have a reason to keep last year's calendar rows. +8. **Step 5 — Preview & apply** + - Review promotions, removals, officers, and warnings. + - Type the target competition year exactly to enable **Apply rollover**. + - Confirm the destructive dialog. The wizard creates `Data/backups/pre-rollover-yyyyMMdd-HHmmss.db` first; that file is the only undo. +9. **Restart the application** so the home page and printouts show the new competition year. +10. **Add new students** via `/students/create` or `/import`. + - `/import` is add-only and skips existing first+last name matches, so re-importing a full roster is safe for returners. +11. **Import the new state schedule** from the calendar import page. +12. On **Meeting Schedule**, click **Reset** once. That page keeps team/student ids in browser localStorage; after a rollover those ids are stale. +13. Collect new event rankings and run team assignment as usual. + +## What is deleted vs kept + +| Cleared | Kept | +|---------|------| +| Non-returning students | Returning students (promoted) | +| All teams | Event definitions (national catalog) | +| All event rankings | Notes (including meeting notes by title) | +| All meeting history attendance snapshots | Database backup under `Data/backups/` | +| Event occurrences (when checked) | | + +## Backup location + +| Environment | Backup path | +|-------------|-------------| +| Local (`dotnet run`) | `WebApp/Data/backups/pre-rollover-*.db` | +| Docker (with `./data:/app/Data`) | Host: `./data/backups/pre-rollover-*.db` (container: `/app/Data/backups/`) | + +## Restore from backup (emergency) + +1. Stop the application (or `docker-compose stop`). +2. Replace `Data/app.db` (local) or `./data/app.db` (Docker host) with a copy of the `pre-rollover-*.db` file from the backups folder. +3. If needed, restore `CompetitionYear` in `Data/appsettings.json` / `./data/appsettings.json` (or Chapter Settings after restart). +4. Start the application.