feat: import leftover student fields into notes and show them on the roster

Store leftover CSV columns on hidden student notes, move catalog import to /events/import, and persist Students index columns from Chapter Settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 23:55:03 -04:00
co-authored by Cursor
parent 4c91db37c2
commit 4cfd85b902
39 changed files with 2437 additions and 166 deletions
+85 -5
View File
@@ -1,4 +1,5 @@
using Core.Entities;
using Core.Notes;
using Core.Services;
using Data;
using Microsoft.EntityFrameworkCore;
@@ -46,8 +47,9 @@ public class NotesService : INotesService
}
return await query
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
.Where(n => n.Title == null || !n.Title.StartsWith("#Student:"))
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt)
.ToListAsync();
}
@@ -67,6 +69,84 @@ public class NotesService : INotesService
.FirstOrDefaultAsync();
}
public async Task<Note?> GetStudentNoteAsync(int studentId)
{
var title = _noteNamingService.GetStudentNoteTitle(studentId);
return await _context.Notes
.AsNoTracking()
.Where(n => n.Title == title && !n.IsDeleted)
.FirstOrDefaultAsync();
}
public async Task<IReadOnlyDictionary<int, Note>> GetStudentNotesAsync(IEnumerable<int> studentIds)
{
var ids = studentIds.Distinct().ToList();
if (ids.Count == 0)
return new Dictionary<int, Note>();
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
var notes = await _context.Notes
.AsNoTracking()
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
.ToListAsync();
Dictionary<int, Note> byStudentId = [];
foreach (var note in notes)
{
if (_noteNamingService.TryParseStudentNoteId(note.Title, out var studentId))
byStudentId[studentId] = note;
}
return byStudentId;
}
public async Task SoftDeleteStudentNotesAsync(IEnumerable<int> studentIds, CancellationToken cancellationToken = default)
{
var ids = studentIds.Distinct().ToList();
if (ids.Count == 0)
return;
var titles = ids.Select(_noteNamingService.GetStudentNoteTitle).ToList();
var notes = await _context.Notes
.Where(n => titles.Contains(n.Title) && !n.IsDeleted)
.ToListAsync(cancellationToken);
if (notes.Count == 0)
return;
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
foreach (var note in notes)
{
_context.NoteHistories.Add(new NoteHistory
{
NoteId = note.Id,
Title = note.Title,
Content = note.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Soft Deleted"
});
note.IsDeleted = true;
note.UpdatedAt = now;
note.LastModifiedBy = userEmail;
}
await _context.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Soft-deleted {Count} student note(s)", notes.Count);
}
public async Task<IReadOnlyList<string>> GetImportedFieldNamesAsync(CancellationToken cancellationToken = default)
{
var contents = await _context.Notes
.AsNoTracking()
.Where(n => !n.IsDeleted && n.Title != null && n.Title.StartsWith("#Student:"))
.Select(n => n.Content)
.ToListAsync(cancellationToken);
return ImportedFieldsTable.DistinctFieldNames(contents);
}
public async Task<IEnumerable<NoteHistory>> GetNoteHistoryAsync(int noteId)
{
return await _context.NoteHistories
@@ -253,9 +333,9 @@ public class NotesService : INotesService
{
return await _context.Notes
.AsNoTracking()
.Where(n => n.IsDeleted)
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.ThenByDescending(n => n.UpdatedAt) // Within each group, order by most recently updated
.Where(n => n.IsDeleted && (n.Title == null || !n.Title.StartsWith("#Student:")))
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt)
.ToListAsync();
}