feat: add locked new-year rollover wizard for season transitions

Promote returning students, assign officers, and clear last season's data after an automatic SQLite backup, with Docker volume path docs fixed for /app/Data.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-14 14:18:18 -04:00
co-authored by Cursor
parent c5c0f95f60
commit afdabd179a
16 changed files with 1677 additions and 50 deletions
@@ -0,0 +1,32 @@
using Core.Models;
namespace Core.YearTransition;
/// <summary>
/// Resolves the graduating grade from chapter school level configuration.
/// </summary>
public static class GraduatingGradeResolver
{
/// <summary>
/// Middle school students graduate after grade 8; high school after grade 12.
/// Returns null when school level is unset (both / unspecified).
/// </summary>
public static int? FromSchoolLevel(SchoolLevel? schoolLevel) => schoolLevel switch
{
SchoolLevel.MiddleSchool => 8,
SchoolLevel.HighSchool => 12,
_ => null
};
/// <summary>
/// Human-readable label for wizard display.
/// </summary>
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}"
};
}
@@ -0,0 +1,294 @@
using Core.Entities;
namespace Core.YearTransition;
/// <summary>
/// Inputs for building a year-transition plan.
/// </summary>
public sealed class YearTransitionRequest
{
public required IReadOnlyList<Student> Students { get; init; }
public required IReadOnlySet<int> ReturningStudentIds { get; init; }
public IReadOnlyDictionary<OfficerRole, int?> OfficerAssignments { get; init; }
= new Dictionary<OfficerRole, int?>();
public required int GraduatingGrade { get; init; }
public required string TargetCompetitionYear { get; init; }
public IReadOnlyList<string> PastedNames { get; init; } = [];
}
/// <summary>
/// Preview of a year transition before it is applied.
/// </summary>
public sealed class YearTransitionPlan
{
public required string TargetCompetitionYear { get; init; }
public required int GraduatingGrade { get; init; }
public required IReadOnlyList<StudentPromotion> Promotions { get; init; }
public required IReadOnlyList<Student> StudentsToRemove { get; init; }
public required IReadOnlyList<OfficerAssignmentChange> OfficerChanges { get; init; }
public required IReadOnlyList<string> UnmatchedPastedNames { get; init; }
public required IReadOnlyList<string> AmbiguousPastedNames { get; init; }
public required IReadOnlyList<string> 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; }
}
/// <summary>
/// Pure planner for year-end student promotion, graduation, and officer assignment.
/// </summary>
public static class YearTransitionPlanner
{
private const int AbsoluteMaxGrade = 12;
/// <summary>
/// Students at or above the graduating grade are suggested as non-returning.
/// </summary>
public static bool SuggestReturning(Student student, int graduatingGrade) =>
student.Grade < graduatingGrade;
/// <summary>
/// Parses pasted name lines and matches them to students.
/// </summary>
public static NameMatchResult MatchPastedNames(
IReadOnlyList<Student> students,
IEnumerable<string> pastedLines)
{
var matchedIds = new HashSet<int>();
var unmatched = new List<string>();
var ambiguous = new List<string>();
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<string>();
var promotions = new List<StudentPromotion>();
var toRemove = new List<Student>();
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<OfficerRole>()
.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<int, OfficerRole>();
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<OfficerRole>()
.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<Student> FindNameMatches(IReadOnlyList<Student> 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));
}
/// <summary>
/// Parses a name line into (first, last), using <see cref="Student.ParseNameParts"/> for
/// "Last, First" and a last-space split for "First Last".
/// </summary>
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<int> matchedStudentIds,
IReadOnlyList<string> unmatchedNames,
IReadOnlyList<string> ambiguousNames)
{
MatchedStudentIds = matchedStudentIds;
UnmatchedNames = unmatchedNames;
AmbiguousNames = ambiguousNames;
}
public IReadOnlySet<int> MatchedStudentIds { get; }
public IReadOnlyList<string> UnmatchedNames { get; }
public IReadOnlyList<string> AmbiguousNames { get; }
}
+28 -2
View File
@@ -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 ## Managing Users
### Adding a New User ### Adding a New User
@@ -310,10 +335,11 @@ curl http://localhost:8080
- [ ] Set file permissions to 600 - [ ] Set file permissions to 600
- [ ] Configured HTTPS/SSL certificates - [ ] Configured HTTPS/SSL certificates
- [ ] Updated `ASPNETCORE_URLS` for production domain - [ ] 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) - [ ] Removed development endpoints (already done in code)
- [ ] Set up log monitoring - [ ] 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 login with all user roles
- [ ] Tested rate limiting (5 failed attempts) - [ ] Tested rate limiting (5 failed attempts)
- [ ] Documented admin password securely - [ ] Documented admin password securely
@@ -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<int> { 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<int> { 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<int> { 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<int> { president.Id, vp.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[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<int> { returning.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[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<int> { student.Id },
GraduatingGrade = 8,
TargetCompetitionYear = "2027",
OfficerAssignments = new Dictionary<OfficerRole, int?>
{
[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<int> { 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"));
}
}
+3 -46
View File
@@ -3,10 +3,10 @@
@using WebApp.Authentication @using WebApp.Authentication
@using WebApp.Models @using WebApp.Models
@using WebApp.Components.Shared.Components @using WebApp.Components.Shared.Components
@using System.Text.Json @using WebApp.Services
@using Core.Models @using Core.Models
@inject IWebHostEnvironment Environment
@inject IConfiguration Configuration @inject IConfiguration Configuration
@inject IChapterSettingsWriter ChapterSettingsWriter
@rendermode InteractiveServer @rendermode InteractiveServer
@@ -125,19 +125,10 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
// Load from IConfiguration
_settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>() _settings = Configuration.GetSection("ChapterSettings").Get<Models.ChapterSettings>()
?? new Models.ChapterSettings(); ?? new Models.ChapterSettings();
} }
private string GetAppSettingsPath()
{
return Path.Combine(
Environment.ContentRootPath,
"Data",
"appsettings.json");
}
private async Task SaveSettings() private async Task SaveSettings()
{ {
if (_settings == null) return; if (_settings == null) return;
@@ -147,41 +138,7 @@
try try
{ {
var appSettingsPath = GetAppSettingsPath(); await ChapterSettingsWriter.WriteAsync(_settings);
// 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<string, object?> settings;
if (File.Exists(appSettingsPath))
{
var existingJson = await File.ReadAllTextAsync(appSettingsPath);
existingDoc = JsonDocument.Parse(existingJson);
settings = JsonSerializer.Deserialize<Dictionary<string, object?>>(existingJson)
?? new Dictionary<string, object?>();
}
else
{
settings = new Dictionary<string, object?>();
}
// 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();
_statusMessage = "Settings saved successfully! Changes will take effect on next application restart."; _statusMessage = "Settings saved successfully! Changes will take effect on next application restart.";
_statusSeverity = Severity.Success; _statusSeverity = Severity.Success;
} }
+651
View File
@@ -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<YearRollover> Logger
@rendermode InteractiveServer
<PageHeader
Title="New Year Rollover"
Description="Promote returning students, set officers, and clear last season's data after an automatic database backup."
ShowBackButton="true"
BackButtonUrl="/settings/chapter" />
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4 mb-8">
@if (_result != null)
{
<MudAlert Severity="Severity.Success" Class="mb-4" Variant="Variant.Filled">
Rollover to @_result.CompetitionYear completed successfully.
</MudAlert>
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Summary</MudText>
<MudText>Backup: <code>@_result.BackupPath</code></MudText>
<MudText>Students promoted: @_result.StudentsPromoted</MudText>
<MudText>Students removed: @_result.StudentsRemoved</MudText>
<MudText>Teams deleted: @_result.TeamsDeleted</MudText>
<MudText>Event rankings deleted: @_result.RankingsDeleted</MudText>
<MudText>Meeting histories deleted: @_result.MeetingHistoriesDeleted</MudText>
<MudText>Event occurrences deleted: @_result.EventOccurrencesDeleted</MudText>
<MudText Typo="Typo.subtitle1" Class="mt-3 mb-1">Officers</MudText>
<MudList T="string" Dense="true">
@foreach (var line in _result.OfficerSummary)
{
<MudListItem T="string" Icon="@Icons.Material.Filled.Badge">@line</MudListItem>
}
</MudList>
<MudAlert Severity="Severity.Info" Class="mt-4" Dense="true">
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.
</MudAlert>
</MudPaper>
}
else if (_students == null)
{
<MudProgressCircular Indeterminate="true" />
}
else if (!_wizardUnlocked)
{
<MudPaper Class="pa-6 mb-4">
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="mb-4" Icon="@Icons.Material.Filled.Lock">
This wizard permanently changes production chapter data. It is locked until you intentionally unlock it.
</MudAlert>
<MudText Typo="Typo.h5" Class="mb-3">Before you continue</MudText>
<MudText Typo="Typo.body2" Class="mb-3">
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.
</MudText>
<MudList T="string" Dense="true" Class="mb-4">
<MudListItem T="string" Icon="@Icons.Material.Filled.DeleteForever" IconColor="Color.Error">
Non-returning students are permanently deleted
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Groups" IconColor="Color.Error">
All teams, event rankings, and meeting history are cleared
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Event" IconColor="Color.Warning">
Event occurrences are cleared by default (state schedule)
</MudListItem>
</MudList>
<MudCheckBox @bind-Value="_ackDestructive" Color="Color.Error" Class="mb-2"
Label="I understand this permanently deletes students and season data" />
<MudCheckBox @bind-Value="_ackBackupOnlyUndo" Color="Color.Error" Class="mb-4"
Label="I understand the automatic backup is the only undo" />
<MudTextField @bind-Value="_unlockPhrase"
Label="@($"Type {UnlockPhrase} to unlock")"
Variant="Variant.Outlined"
HelperText="@($"Confirmation is case-insensitive. Type exactly: {UnlockPhrase}")"
Class="mb-4"
Style="max-width: 320px;"
Immediate="true" />
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.LockOpen"
Disabled="!CanUnlockWizard"
OnClick="UnlockWizard">
Unlock wizard
</MudButton>
</MudPaper>
}
else
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.LockOpen">
Wizard unlocked for this session. Close or refresh this page to lock it again.
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Default" Class="ml-2" OnClick="LockWizard">
Lock again
</MudButton>
</MudAlert>
<MudStepper @bind-ActiveIndex="Step" Class="mb-4">
<MudStep Title="Year">Year &amp; grades</MudStep>
<MudStep Title="Roster">Returning roster</MudStep>
<MudStep Title="Officers">Officers</MudStep>
<MudStep Title="Reset">Season reset</MudStep>
<MudStep Title="Apply">Preview &amp; apply</MudStep>
</MudStepper>
@if (Step == 0)
{
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Competition year</MudText>
<MudTextField @bind-Value="_targetYear"
Label="Target competition year"
Variant="Variant.Outlined"
HelperText="Defaults to current year + 1"
Class="mb-4"
Style="max-width: 200px;" />
<MudText Typo="Typo.h5" Class="mb-2">Chapter type</MudText>
@if (_configuredSchoolLevel is { } configured)
{
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
@GraduatingGradeResolver.Describe(configured, _graduatingGrade!.Value)
<MudLink Href="/settings/chapter" Class="ml-2">Change in Chapter Settings</MudLink>
</MudAlert>
}
else
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
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 <MudLink Href="/settings/chapter">Chapter Settings</MudLink> page.
</MudAlert>
<MudSelect T="SchoolLevel?" Value="_overrideSchoolLevel" Label="Chapter type for this rollover"
Variant="Variant.Outlined" Class="mb-3" Style="max-width: 320px;"
ValueChanged="OnOverrideSchoolLevelChanged">
<MudSelectItem T="SchoolLevel?" Value="@SchoolLevel.MiddleSchool">Middle School (graduate after grade 8)</MudSelectItem>
<MudSelectItem T="SchoolLevel?" Value="@SchoolLevel.HighSchool">High School (graduate after grade 12)</MudSelectItem>
</MudSelect>
}
<MudAlert Severity="Severity.Normal" Dense="true" Class="mt-2">
Applying the rollover will create an automatic backup at
<code>Data/backups/pre-rollover-*.db</code> before making any changes.
That backup is the only undo.
</MudAlert>
</MudPaper>
}
else if (Step == 1)
{
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Returning students</MudText>
<MudText Typo="Typo.body2" Class="mb-3 mud-text-secondary">
Students at or above graduating grade @_graduatingGrade are unchecked by default.
Paste a list of names (one per line) to check matches.
</MudText>
<MudTextField @bind-Value="_pasteBox"
Label="Paste returning names (optional)"
Variant="Variant.Outlined"
Lines="4"
Class="mb-2" />
<MudButton Variant="Variant.Outlined" Size="Size.Small" Class="mb-4" OnClick="ApplyPastedNames"
StartIcon="@Icons.Material.Filled.ContentPaste">
Apply pasted names
</MudButton>
@if (_pasteUnmatched.Count > 0)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
Unmatched: @string.Join("; ", _pasteUnmatched)
</MudAlert>
}
@if (_pasteAmbiguous.Count > 0)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
Ambiguous (check manually): @string.Join("; ", _pasteAmbiguous)
</MudAlert>
}
<MudTable Items="_students" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>Returning</MudTh>
<MudTh>Name</MudTh>
<MudTh>Grade</MudTh>
<MudTh>TSA Year</MudTh>
<MudTh>Officer</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudCheckBox T="bool" Value="_returningIds.Contains(context.Id)"
ValueChanged="(bool v) => SetReturning(context.Id, v)"
Dense="true" Color="Color.Primary" />
</MudTd>
<MudTd>@context.LastNameFirstName</MudTd>
<MudTd>@context.Grade</MudTd>
<MudTd>@context.TsaYear</MudTd>
<MudTd>@(context.OfficerRole?.ToString() ?? "—")</MudTd>
</RowTemplate>
</MudTable>
<MudText Typo="Typo.caption" Class="mt-2">
@_returningIds.Count returning · @(_students.Count - _returningIds.Count) will be removed
</MudText>
</MudPaper>
}
else if (Step == 2)
{
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">New officer slate</MudText>
<MudText Typo="Typo.body2" Class="mb-4 mud-text-secondary">
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.
</MudText>
<MudGrid>
@foreach (var role in _officerRoles)
{
<MudItem xs="12" md="6">
<MudSelect T="int?" Value="GetOfficerSelection(role)"
ValueChanged="(int? id) => SetOfficerSelection(role, id)"
Label="@role.ToString()"
Variant="Variant.Outlined"
Clearable="true">
@foreach (var student in ReturningStudents)
{
<MudSelectItem T="int?" Value="@student.Id">@student.LastNameFirstName</MudSelectItem>
}
</MudSelect>
</MudItem>
}
</MudGrid>
</MudPaper>
}
else if (Step == 3)
{
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Season reset</MudText>
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-3">
The following are always cleared: all teams, all event rankings, and all meeting history records.
Written notes on the Notes page are not affected.
</MudAlert>
<MudCheckBox @bind-Value="_clearEventOccurrences" Color="Color.Primary" Label="Clear all event occurrences (state schedule)" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">
Leave this checked unless you plan to keep last year's calendar rows. Import the new schedule afterward.
</MudText>
</MudPaper>
}
else if (Step == 4)
{
var plan = BuildPlan();
<MudPaper Class="pa-6 mb-4">
<MudText Typo="Typo.h5" Class="mb-3">Preview</MudText>
<MudText>Competition year → <strong>@plan.TargetCompetitionYear</strong></MudText>
<MudText>Promote <strong>@plan.ReturningCount</strong> students · Remove <strong>@plan.RemovalCount</strong> students</MudText>
<MudText>Clear teams, rankings, meeting history@( _clearEventOccurrences ? ", and event occurrences" : "" )</MudText>
@if (plan.Warnings.Count > 0)
{
<MudAlert Severity="Severity.Warning" Class="mt-3 mb-2">
<MudText Typo="Typo.subtitle2">Warnings</MudText>
<ul class="mb-0">
@foreach (var warning in plan.Warnings)
{
<li>@warning</li>
}
</ul>
</MudAlert>
}
<MudExpansionPanels Class="mt-3 mb-3">
<MudExpansionPanel Text="@($"Promotions ({plan.ReturningCount})")">
<MudTable Items="plan.Promotions" Dense="true" Hover="true">
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Grade</MudTh>
<MudTh>TSA Year</MudTh>
<MudTh>Officer</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Student.LastNameFirstName</MudTd>
<MudTd>@context.PreviousGrade → @context.NewGrade</MudTd>
<MudTd>@context.PreviousTsaYear → @context.NewTsaYear</MudTd>
<MudTd>@(context.PreviousOfficerRole?.ToString() ?? "—") → @(context.NewOfficerRole?.ToString() ?? "—")</MudTd>
</RowTemplate>
</MudTable>
</MudExpansionPanel>
<MudExpansionPanel Text="@($"Removals ({plan.RemovalCount})")">
<MudList T="string" Dense="true">
@foreach (var student in plan.StudentsToRemove)
{
<MudListItem T="string">@student.LastNameFirstName (grade @student.Grade)</MudListItem>
}
</MudList>
</MudExpansionPanel>
<MudExpansionPanel Text="Officers">
<MudList T="string" Dense="true">
@foreach (var change in plan.OfficerChanges)
{
<MudListItem T="string">
@change.Role:
@(change.NewOfficer?.LastNameFirstName ?? "(vacant)")
@if (change.PreviousOfficer != null)
{
<span class="mud-text-secondary"> (was @change.PreviousOfficer.LastNameFirstName)</span>
}
</MudListItem>
}
</MudList>
</MudExpansionPanel>
</MudExpansionPanels>
<MudTextField @bind-Value="_applyConfirmYear"
Label="@($"Type {_targetYear.Trim()} to enable Apply")"
Variant="Variant.Outlined"
HelperText="Must exactly match the target competition year above"
Class="mb-4"
Style="max-width: 280px;"
Immediate="true" />
<MudButton Variant="Variant.Filled"
Color="Color.Error"
StartIcon="@Icons.Material.Filled.Warning"
Disabled="_isApplying || !CanApply"
OnClick="ConfirmAndApply">
@if (_isApplying)
{
<MudProgressCircular Class="mr-2" Size="Size.Small" Indeterminate="true" />
<span>Applying...</span>
}
else
{
<span>Apply rollover</span>
}
</MudButton>
</MudPaper>
}
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mt-2">
<MudButton Variant="Variant.Text"
Disabled="Step == 0 || _isApplying"
OnClick="() => Step--">
Back
</MudButton>
@if (Step < 4)
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Disabled="!CanGoNext"
OnClick="GoNext">
Next
</MudButton>
}
</MudStack>
}
</MudContainer>
@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<Student>? _students;
private HashSet<int> _returningIds = [];
private Dictionary<OfficerRole, int?> _officerSelections = [];
private readonly OfficerRole[] _officerRoles = Enum.GetValues<OfficerRole>();
private string _targetYear = "2027";
private SchoolLevel? _configuredSchoolLevel;
private SchoolLevel? _overrideSchoolLevel;
private int? _graduatingGrade;
private string _pasteBox = "";
private List<string> _pasteUnmatched = [];
private List<string> _pasteAmbiguous = [];
private bool _clearEventOccurrences = true;
private bool _isApplying;
private YearRolloverResult? _result;
private bool _returningInitialized;
private IEnumerable<Student> ReturningStudents =>
_students?.Where(s => _returningIds.Contains(s.Id)).OrderBy(s => s.LastName).ThenBy(s => s.FirstName)
?? Enumerable.Empty<Student>();
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<WebApp.Models.ChapterSettings>()?.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<SchoolLevel>(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;
}
}
@@ -35,6 +35,7 @@
<AuthorizeView Roles="Administrator"> <AuthorizeView Roles="Administrator">
<MudDivider Class="my-2"/> <MudDivider Class="my-2"/>
<MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink> <MudNavLink Href="/settings/chapter" Icon="@Icons.Material.Filled.School">Chapter Settings</MudNavLink>
<MudNavLink Href="/settings/new-year" Icon="@Icons.Material.Filled.EventRepeat">New Year Rollover (locked)</MudNavLink>
<MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink> <MudNavLink Href="/settings/validation" Icon="@Icons.Material.Filled.Tune">Validation Settings</MudNavLink>
</AuthorizeView> </AuthorizeView>
</MudNavMenu> </MudNavMenu>
+3
View File
@@ -201,6 +201,9 @@ builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>(); builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
builder.Services.AddScoped<WebApp.Services.IChapterSettingsWriter, WebApp.Services.ChapterSettingsWriter>();
builder.Services.AddScoped<WebApp.Services.IDatabaseBackupService, WebApp.Services.DatabaseBackupService>();
builder.Services.AddScoped<WebApp.Services.IYearRolloverService, WebApp.Services.YearRolloverService>();
builder.Services.Configure<StateScheduleHandoutOptions>( builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName)); builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
+67
View File
@@ -0,0 +1,67 @@
using System.Text.Json;
using WebApp.Models;
namespace WebApp.Services;
/// <summary>
/// Persists chapter settings to <c>Data/appsettings.json</c>.
/// </summary>
public class ChapterSettingsWriter : IChapterSettingsWriter
{
private readonly IWebHostEnvironment _environment;
private readonly IConfiguration _configuration;
private readonly ILogger<ChapterSettingsWriter> _logger;
public ChapterSettingsWriter(
IWebHostEnvironment environment,
IConfiguration configuration,
ILogger<ChapterSettingsWriter> 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<string, object?> root;
if (File.Exists(appSettingsPath))
{
var existingJson = await File.ReadAllTextAsync(appSettingsPath, cancellationToken);
root = JsonSerializer.Deserialize<Dictionary<string, object?>>(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<ChapterSettings>()
?? new ChapterSettings();
settings.CompetitionYear = competitionYear;
await WriteAsync(settings, cancellationToken);
}
private string GetAppSettingsPath() =>
Path.Combine(_environment.ContentRootPath, "Data", "appsettings.json");
}
+48
View File
@@ -0,0 +1,48 @@
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
/// <summary>
/// Creates SQLite database backups via VACUUM INTO.
/// </summary>
public class DatabaseBackupService : IDatabaseBackupService
{
private readonly AppDbContext _context;
private readonly IWebHostEnvironment _environment;
private readonly ILogger<DatabaseBackupService> _logger;
public DatabaseBackupService(
AppDbContext context,
IWebHostEnvironment environment,
ILogger<DatabaseBackupService> logger)
{
_context = context;
_environment = environment;
_logger = logger;
}
public async Task<string> 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;
}
}
+19
View File
@@ -0,0 +1,19 @@
using WebApp.Models;
namespace WebApp.Services;
/// <summary>
/// Persists chapter settings to <c>Data/appsettings.json</c>.
/// </summary>
public interface IChapterSettingsWriter
{
/// <summary>
/// Writes the given chapter settings, preserving other top-level sections in the file.
/// </summary>
Task WriteAsync(ChapterSettings settings, CancellationToken cancellationToken = default);
/// <summary>
/// Updates only the competition year while preserving other chapter settings from configuration.
/// </summary>
Task UpdateCompetitionYearAsync(string competitionYear, CancellationToken cancellationToken = default);
}
+13
View File
@@ -0,0 +1,13 @@
namespace WebApp.Services;
/// <summary>
/// Creates SQLite database backups.
/// </summary>
public interface IDatabaseBackupService
{
/// <summary>
/// Creates a pre-rollover backup of the application database using SQLite VACUUM INTO.
/// </summary>
/// <returns>The absolute path of the backup file.</returns>
Task<string> CreatePreRolloverBackupAsync(CancellationToken cancellationToken = default);
}
+36
View File
@@ -0,0 +1,36 @@
using Core.YearTransition;
namespace WebApp.Services;
/// <summary>
/// Options for applying a year rollover.
/// </summary>
public sealed class YearRolloverOptions
{
public required YearTransitionPlan Plan { get; init; }
public bool ClearEventOccurrences { get; init; } = true;
}
/// <summary>
/// Result of a successful year rollover.
/// </summary>
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<string> OfficerSummary { get; init; }
}
/// <summary>
/// Applies a year-transition plan to the database.
/// </summary>
public interface IYearRolloverService
{
Task<YearRolloverResult> ApplyAsync(YearRolloverOptions options, CancellationToken cancellationToken = default);
}
+182
View File
@@ -0,0 +1,182 @@
using Core.YearTransition;
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
/// <summary>
/// Applies a year-transition plan: backup, wipe season data, promote/remove students, update year.
/// </summary>
public class YearRolloverService : IYearRolloverService
{
private readonly AppDbContext _context;
private readonly IDatabaseBackupService _backupService;
private readonly IChapterSettingsWriter _chapterSettingsWriter;
private readonly ILogger<YearRolloverService> _logger;
public YearRolloverService(
AppDbContext context,
IDatabaseBackupService backupService,
IChapterSettingsWriter chapterSettingsWriter,
ILogger<YearRolloverService> logger)
{
_context = context;
_backupService = backupService;
_chapterSettingsWriter = chapterSettingsWriter;
_logger = logger;
}
public async Task<YearRolloverResult> 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<string> 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;
}
}
+3 -2
View File
@@ -26,8 +26,9 @@ services:
# Option 1: Volume-mounted secrets file (recommended for easy editing) # Option 1: Volume-mounted secrets file (recommended for easy editing)
- ./auth-secrets.json:/app/secrets/auth-secrets.json:ro - ./auth-secrets.json:/app/secrets/auth-secrets.json:ro
# Database persistence # Database persistence — must use capital D (/app/Data). Linux paths are
- ./data:/app/data # case-sensitive; ./data:/app/data will NOT persist app.db or rollover backups.
- ./data:/app/Data
# HTTPS certificate (if needed) # HTTPS certificate (if needed)
# - ./certs:/https:ro # - ./certs:/https:ro
+67
View File
@@ -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.