Files
chapter-organizer/WebApp/Services/NotesService.cs
T
poprhythmandCursor 4cfd85b902 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>
2026-08-29 23:55:03 -04:00

383 lines
12 KiB
C#

using Core.Entities;
using Core.Notes;
using Core.Services;
using Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace WebApp.Services;
public class NotesService : INotesService
{
private readonly AppDbContext _context;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<NotesService> _logger;
private readonly INoteNamingService _noteNamingService;
public NotesService(
AppDbContext context,
IHttpContextAccessor httpContextAccessor,
ILogger<NotesService> logger,
INoteNamingService noteNamingService)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_noteNamingService = noteNamingService;
}
private string? GetCurrentUserEmail()
{
var user = _httpContextAccessor.HttpContext?.User;
if (user == null)
return null;
return user.FindFirstValue(ClaimTypes.Email) ?? user.Identity?.Name;
}
public async Task<IEnumerable<Note>> GetNotesAsync(bool includeDeleted = false)
{
var query = _context.Notes
.AsNoTracking()
.AsQueryable();
if (!includeDeleted)
{
query = query.Where(n => !n.IsDeleted);
}
return await query
.Where(n => n.Title == null || !n.Title.StartsWith("#Student:"))
.OrderBy(n => n.Title != null && n.Title.StartsWith("#") ? 1 : 0)
.ThenByDescending(n => n.UpdatedAt)
.ToListAsync();
}
public async Task<Note?> GetNoteAsync(int id)
{
return await _context.Notes
.Include(n => n.NoteHistories.OrderByDescending(h => h.ModifiedAt))
.FirstOrDefaultAsync(n => n.Id == id);
}
public async Task<Note?> GetPageNoteAsync(string pageIdentifier)
{
var pageNoteTitle = _noteNamingService.GetPageNoteTitle(pageIdentifier);
return await _context.Notes
.AsNoTracking()
.Where(n => n.Title == pageNoteTitle && !n.IsDeleted)
.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
.AsNoTracking()
.Where(h => h.NoteId == noteId)
.OrderByDescending(h => h.ModifiedAt)
.Take(10)
.ToListAsync();
}
public async Task<Note> CreateNoteAsync(Note note)
{
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
note.CreatedAt = now;
note.UpdatedAt = now;
note.CreatedBy = userEmail;
note.LastModifiedBy = userEmail;
_context.Notes.Add(note);
await _context.SaveChangesAsync();
// Create initial history entry
var history = new NoteHistory
{
NoteId = note.Id,
Title = note.Title,
Content = note.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Created"
};
_context.NoteHistories.Add(history);
await _context.SaveChangesAsync();
_logger.LogInformation("Note created: {NoteId} by {User}", note.Id, userEmail);
return note;
}
public async Task<Note> UpdateNoteAsync(Note note)
{
var existingNote = await _context.Notes
.FirstOrDefaultAsync(n => n.Id == note.Id);
if (existingNote == null)
{
throw new InvalidOperationException($"Note with ID {note.Id} not found.");
}
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
// Create history entry with previous state before updating
var history = new NoteHistory
{
NoteId = existingNote.Id,
Title = existingNote.Title,
Content = existingNote.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Updated"
};
_context.NoteHistories.Add(history);
// Update the note
existingNote.Title = note.Title;
existingNote.Content = note.Content;
existingNote.UpdatedAt = now;
existingNote.LastModifiedBy = userEmail;
await _context.SaveChangesAsync();
_logger.LogInformation("Note updated: {NoteId} by {User}", note.Id, userEmail);
return existingNote;
}
public async Task DeleteNoteAsync(int id)
{
var note = await _context.Notes
.FirstOrDefaultAsync(n => n.Id == id);
if (note == null)
{
throw new InvalidOperationException($"Note with ID {id} not found.");
}
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
// Create deletion history entry
var history = new NoteHistory
{
NoteId = note.Id,
Title = note.Title,
Content = note.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Soft Deleted"
};
_context.NoteHistories.Add(history);
// Soft delete - set IsDeleted flag instead of removing
note.IsDeleted = true;
note.UpdatedAt = now;
note.LastModifiedBy = userEmail;
await _context.SaveChangesAsync();
_logger.LogInformation("Note soft deleted: {NoteId} by {User}", id, userEmail);
}
public async Task<IEnumerable<Note>> GetPinnedNotesAsync()
{
return await _context.Notes
.AsNoTracking()
.Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted)
.OrderByDescending(n => n.UpdatedAt)
.Take(3)
.ToListAsync();
}
public async Task TogglePinNoteAsync(int noteId)
{
var note = await _context.Notes
.FirstOrDefaultAsync(n => n.Id == noteId);
if (note == null)
{
throw new InvalidOperationException($"Note with ID {noteId} not found.");
}
// Prevent pinning page notes
if (_noteNamingService.IsPageNote(note.Title))
{
throw new InvalidOperationException("Page notes cannot be pinned.");
}
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
// If pinning and already 3 pinned notes exist (excluding this note if it's already pinned)
if (!note.IsPinned)
{
var pinnedCount = await _context.Notes
.CountAsync(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted);
if (pinnedCount >= 3)
{
// Unpin the oldest pinned note
var oldestPinned = await _context.Notes
.Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted)
.OrderBy(n => n.UpdatedAt)
.FirstOrDefaultAsync();
if (oldestPinned != null)
{
oldestPinned.IsPinned = false;
oldestPinned.UpdatedAt = now;
oldestPinned.LastModifiedBy = userEmail;
_logger.LogInformation("Auto-unpinned note {NoteId} (oldest) when pinning note {NewNoteId} by {User}",
oldestPinned.Id, noteId, userEmail);
}
}
}
// Toggle the pin status
note.IsPinned = !note.IsPinned;
note.UpdatedAt = now;
note.LastModifiedBy = userEmail;
await _context.SaveChangesAsync();
_logger.LogInformation("Note {NoteId} {Action} by {User}", noteId,
note.IsPinned ? "pinned" : "unpinned", userEmail);
}
public async Task<IEnumerable<Note>> GetDeletedNotesAsync()
{
return await _context.Notes
.AsNoTracking()
.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();
}
public async Task RestoreNoteAsync(int noteId)
{
var note = await _context.Notes
.FirstOrDefaultAsync(n => n.Id == noteId);
if (note == null)
{
throw new InvalidOperationException($"Note with ID {noteId} not found.");
}
if (!note.IsDeleted)
{
throw new InvalidOperationException($"Note with ID {noteId} is not deleted.");
}
var userEmail = GetCurrentUserEmail();
var now = DateTime.UtcNow;
// Create restore history entry
var history = new NoteHistory
{
NoteId = note.Id,
Title = note.Title,
Content = note.Content,
ModifiedBy = userEmail,
ModifiedAt = now,
ChangeType = "Restored"
};
_context.NoteHistories.Add(history);
// Restore the note
note.IsDeleted = false;
note.UpdatedAt = now;
note.LastModifiedBy = userEmail;
await _context.SaveChangesAsync();
_logger.LogInformation("Note restored: {NoteId} by {User}", noteId, userEmail);
}
}