Add TeamMeetingHistory entity, service, and UI components for meeting history management

This commit introduces the TeamMeetingHistory entity, including its configuration and database migrations. A new ITeamMeetingHistoryService interface and its implementation, TeamMeetingHistoryService, are added to handle CRUD operations for meeting histories. Additionally, UI components such as History.razor, MeetingHistoryDetailDialog, and SaveMeetingHistoryDialog are created to facilitate viewing and saving meeting histories. The integration of INoteNamingService enhances note management for meeting records, improving overall functionality and user experience in the application.
This commit is contained in:
2026-01-19 22:02:59 -05:00
parent 9ed9c93540
commit 6bc4c2e7f2
21 changed files with 2102 additions and 16 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ public interface INotesService
/// Gets a page-specific note by page identifier.
/// </summary>
/// <param name="pageIdentifier">The page identifier (e.g., "Teams", "Registration")</param>
/// <returns>The note with title "@{pageIdentifier}" or null if not found</returns>
/// <returns>The note with title "#{pageIdentifier}" or null if not found</returns>
Task<Note?> GetPageNoteAsync(string pageIdentifier);
/// <summary>
@@ -0,0 +1,50 @@
using Core.Entities;
namespace WebApp.Services;
public interface ITeamMeetingHistoryService
{
/// <summary>
/// Gets all meeting histories within an optional date range.
/// </summary>
/// <param name="startDate">Optional start date filter</param>
/// <param name="endDate">Optional end date filter</param>
Task<IEnumerable<TeamMeetingHistory>> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null);
/// <summary>
/// Gets a specific meeting history by ID with teams and students included.
/// </summary>
Task<TeamMeetingHistory?> GetMeetingHistoryAsync(int id);
/// <summary>
/// Creates a new meeting history record.
/// </summary>
Task<TeamMeetingHistory> CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory);
/// <summary>
/// Updates an existing meeting history record.
/// </summary>
Task<TeamMeetingHistory> UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory);
/// <summary>
/// Deletes a meeting history record.
/// </summary>
Task DeleteMeetingHistoryAsync(int id);
/// <summary>
/// Gets which teams met on a specific date.
/// </summary>
Task<IEnumerable<Team>> GetTeamsForDateAsync(DateTime date);
/// <summary>
/// Gets which students were present on a specific date.
/// </summary>
Task<IEnumerable<Student>> GetStudentsForDateAsync(DateTime date);
/// <summary>
/// Gets the meeting note for a specific meeting date by looking it up by title.
/// </summary>
/// <param name="meetingDate">The date of the meeting</param>
/// <returns>The note if found, null otherwise</returns>
Task<Note?> GetMeetingNoteAsync(DateTime meetingDate);
}
+12 -8
View File
@@ -1,4 +1,5 @@
using Core.Entities;
using Core.Services;
using Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
@@ -10,15 +11,18 @@ 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)
ILogger<NotesService> logger,
INoteNamingService noteNamingService)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_noteNamingService = noteNamingService;
}
private string? GetCurrentUserEmail()
@@ -42,7 +46,7 @@ public class NotesService : INotesService
}
return await query
.OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.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
.ToListAsync();
}
@@ -56,7 +60,7 @@ public class NotesService : INotesService
public async Task<Note?> GetPageNoteAsync(string pageIdentifier)
{
var pageNoteTitle = $"@{pageIdentifier}";
var pageNoteTitle = _noteNamingService.GetPageNoteTitle(pageIdentifier);
return await _context.Notes
.AsNoTracking()
.Where(n => n.Title == pageNoteTitle && !n.IsDeleted)
@@ -184,7 +188,7 @@ public class NotesService : INotesService
{
return await _context.Notes
.AsNoTracking()
.Where(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted)
.Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted)
.OrderByDescending(n => n.UpdatedAt)
.Take(3)
.ToListAsync();
@@ -201,7 +205,7 @@ public class NotesService : INotesService
}
// Prevent pinning page notes
if (note.Title.StartsWith("@"))
if (_noteNamingService.IsPageNote(note.Title))
{
throw new InvalidOperationException("Page notes cannot be pinned.");
}
@@ -213,13 +217,13 @@ public class NotesService : INotesService
if (!note.IsPinned)
{
var pinnedCount = await _context.Notes
.CountAsync(n => n.IsPinned && !n.Title.StartsWith("@") && !n.IsDeleted);
.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.StartsWith("@") && !n.IsDeleted)
.Where(n => n.IsPinned && (n.Title == null || !n.Title.StartsWith("#")) && !n.IsDeleted)
.OrderBy(n => n.UpdatedAt)
.FirstOrDefaultAsync();
@@ -250,7 +254,7 @@ public class NotesService : INotesService
return await _context.Notes
.AsNoTracking()
.Where(n => n.IsDeleted)
.OrderBy(n => n.Title.StartsWith("@") ? 1 : 0) // Non-page notes first (0), page notes last (1)
.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
.ToListAsync();
}
@@ -0,0 +1,205 @@
using Core.Entities;
using Core.Services;
using Data;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace WebApp.Services;
public class TeamMeetingHistoryService : ITeamMeetingHistoryService
{
private readonly AppDbContext _context;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<TeamMeetingHistoryService> _logger;
private readonly INotesService _notesService;
private readonly INoteNamingService _noteNamingService;
public TeamMeetingHistoryService(
AppDbContext context,
IHttpContextAccessor httpContextAccessor,
ILogger<TeamMeetingHistoryService> logger,
INotesService notesService,
INoteNamingService noteNamingService)
{
_context = context;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
_notesService = notesService;
_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<TeamMeetingHistory>> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null)
{
var query = _context.TeamMeetingHistories
.AsNoTracking()
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Event)
.Include(tmh => tmh.Students)
.AsQueryable();
if (startDate.HasValue)
{
var start = startDate.Value.Date;
query = query.Where(tmh => tmh.MeetingDate >= start);
}
if (endDate.HasValue)
{
var end = endDate.Value.Date.AddDays(1); // Include the entire end date
query = query.Where(tmh => tmh.MeetingDate < end);
}
return await query
.OrderByDescending(tmh => tmh.MeetingDate)
.ToListAsync();
}
public async Task<TeamMeetingHistory?> GetMeetingHistoryAsync(int id)
{
return await _context.TeamMeetingHistories
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Event)
.Include(tmh => tmh.Teams)
.ThenInclude(t => t.Students)
.Include(tmh => tmh.Students)
.FirstOrDefaultAsync(tmh => tmh.Id == id);
}
public async Task<TeamMeetingHistory> CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory)
{
// Create a new meeting history entity to avoid tracking conflicts
var newMeetingHistory = new TeamMeetingHistory
{
MeetingDate = meetingHistory.MeetingDate.Date // Normalize to date only
};
// Attach teams by loading them from the database to avoid tracking conflicts
var teamIds = meetingHistory.Teams.Select(t => t.Id).ToList();
var teams = await _context.Teams
.Where(t => teamIds.Contains(t.Id))
.ToListAsync();
// Attach students by loading them from the database to avoid tracking conflicts
var studentIds = meetingHistory.Students.Select(s => s.Id).ToList();
var students = await _context.Students
.Where(s => studentIds.Contains(s.Id))
.ToListAsync();
// Attach teams and students to the new meeting history
newMeetingHistory.Teams = teams;
newMeetingHistory.Students = students;
_context.TeamMeetingHistories.Add(newMeetingHistory);
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history created: {MeetingHistoryId} for date {MeetingDate}",
newMeetingHistory.Id, newMeetingHistory.MeetingDate);
return newMeetingHistory;
}
public async Task<TeamMeetingHistory> UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory)
{
var existingHistory = await _context.TeamMeetingHistories
.Include(tmh => tmh.Teams)
.Include(tmh => tmh.Students)
.FirstOrDefaultAsync(tmh => tmh.Id == meetingHistory.Id);
if (existingHistory == null)
{
throw new InvalidOperationException($"Meeting history with ID {meetingHistory.Id} not found.");
}
// Update properties
existingHistory.MeetingDate = meetingHistory.MeetingDate.Date; // Normalize to date only
// Update teams
existingHistory.Teams.Clear();
foreach (var team in meetingHistory.Teams)
{
var existingTeam = await _context.Teams.FindAsync(team.Id);
if (existingTeam != null)
{
existingHistory.Teams.Add(existingTeam);
}
}
// Update students
existingHistory.Students.Clear();
foreach (var student in meetingHistory.Students)
{
var existingStudent = await _context.Students.FindAsync(student.Id);
if (existingStudent != null)
{
existingHistory.Students.Add(existingStudent);
}
}
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history updated: {MeetingHistoryId} by {User}",
meetingHistory.Id, GetCurrentUserEmail());
return existingHistory;
}
public async Task DeleteMeetingHistoryAsync(int id)
{
var meetingHistory = await _context.TeamMeetingHistories
.FirstOrDefaultAsync(tmh => tmh.Id == id);
if (meetingHistory == null)
{
throw new InvalidOperationException($"Meeting history with ID {id} not found.");
}
_context.TeamMeetingHistories.Remove(meetingHistory);
await _context.SaveChangesAsync();
_logger.LogInformation("Meeting history deleted: {MeetingHistoryId}", id);
}
/// <summary>
/// Gets the meeting note for a specific meeting date by looking it up by title.
/// </summary>
/// <param name="meetingDate">The date of the meeting</param>
/// <returns>The note if found, null otherwise</returns>
public async Task<Note?> GetMeetingNoteAsync(DateTime meetingDate)
{
var noteTitle = _noteNamingService.GetMeetingNoteTitle(meetingDate);
var notes = await _notesService.GetNotesAsync(includeDeleted: false);
return notes.FirstOrDefault(n => n.Title == noteTitle);
}
public async Task<IEnumerable<Team>> GetTeamsForDateAsync(DateTime date)
{
var normalizedDate = date.Date;
return await _context.TeamMeetingHistories
.AsNoTracking()
.Where(tmh => tmh.MeetingDate == normalizedDate)
.SelectMany(tmh => tmh.Teams)
.Include(t => t.Event)
.Distinct()
.ToListAsync();
}
public async Task<IEnumerable<Student>> GetStudentsForDateAsync(DateTime date)
{
var normalizedDate = date.Date;
return await _context.TeamMeetingHistories
.AsNoTracking()
.Where(tmh => tmh.MeetingDate == normalizedDate)
.SelectMany(tmh => tmh.Students)
.Distinct()
.ToListAsync();
}
}