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:
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user