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>
383 lines
12 KiB
C#
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);
|
|
}
|
|
}
|