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
+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;
}
}