feat: add a Tools page printer for note merge and print presets

Chapter officers can merge a markdown note onto filtered students, teams, or events and save the recipe. Extra student-note columns are additional fields (any ## … fields heading) so they work as print tokens whether imported or typed by hand.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 15:38:43 -04:00
co-authored by Cursor
parent 4cfd85b902
commit 3f50d6e635
39 changed files with 2199 additions and 69 deletions
+97
View File
@@ -0,0 +1,97 @@
using Core.Entities;
using Data;
using Microsoft.EntityFrameworkCore;
namespace WebApp.Services;
public class PrintPresetService : IPrintPresetService
{
private readonly AppDbContext _context;
private readonly ILogger<PrintPresetService> _logger;
public PrintPresetService(AppDbContext context, ILogger<PrintPresetService> logger)
{
_context = context;
_logger = logger;
}
public async Task<IReadOnlyList<PrintPreset>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.PrintPresets
.AsNoTracking()
.Include(p => p.Note)
.OrderBy(p => p.Name)
.ToListAsync(cancellationToken);
}
public async Task<PrintPreset?> GetAsync(int id, CancellationToken cancellationToken = default)
{
return await _context.PrintPresets
.AsNoTracking()
.Include(p => p.Note)
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
}
public async Task<bool> NameExistsAsync(
string name,
int? excludeId = null,
CancellationToken cancellationToken = default)
{
var trimmed = name.Trim();
var query = _context.PrintPresets.AsNoTracking().Where(p => p.Name == trimmed);
if (excludeId.HasValue)
query = query.Where(p => p.Id != excludeId.Value);
return await query.AnyAsync(cancellationToken);
}
public async Task<PrintPreset> CreateAsync(PrintPreset preset, CancellationToken cancellationToken = default)
{
preset.Name = preset.Name.Trim();
preset.UpdatedAt = DateTime.UtcNow;
preset.Note = null!;
if (await NameExistsAsync(preset.Name, null, cancellationToken))
throw new InvalidOperationException($"A print preset named '{preset.Name}' already exists.");
_context.PrintPresets.Add(preset);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Print preset created: {PresetId} {Name}", preset.Id, preset.Name);
return preset;
}
public async Task<PrintPreset> UpdateAsync(PrintPreset preset, CancellationToken cancellationToken = default)
{
var existing = await _context.PrintPresets
.FirstOrDefaultAsync(p => p.Id == preset.Id, cancellationToken);
if (existing is null)
throw new InvalidOperationException($"Print preset {preset.Id} was not found.");
var name = preset.Name.Trim();
if (await NameExistsAsync(name, preset.Id, cancellationToken))
throw new InvalidOperationException($"A print preset named '{name}' already exists.");
existing.Name = name;
existing.NoteId = preset.NoteId;
existing.EntityType = preset.EntityType;
existing.FiltersJson = preset.FiltersJson;
existing.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Print preset updated: {PresetId} {Name}", existing.Id, existing.Name);
return existing;
}
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
{
var existing = await _context.PrintPresets
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
if (existing is null)
return;
_context.PrintPresets.Remove(existing);
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Print preset deleted: {PresetId} {Name}", id, existing.Name);
}
}