diff --git a/Core/Entities/TeamMeetingHistory.cs b/Core/Entities/TeamMeetingHistory.cs new file mode 100644 index 0000000..af7df3a --- /dev/null +++ b/Core/Entities/TeamMeetingHistory.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Core.Entities; + +public class TeamMeetingHistory +{ + public int Id { get; set; } + + [Required] + [Display(Name = "Meeting Date")] + public DateTime MeetingDate { get; set; } + + // Navigation properties + public List Teams { get; set; } = []; + public List Students { get; set; } = []; +} diff --git a/Core/Services/INoteNamingService.cs b/Core/Services/INoteNamingService.cs new file mode 100644 index 0000000..ff37eb9 --- /dev/null +++ b/Core/Services/INoteNamingService.cs @@ -0,0 +1,38 @@ +namespace Core.Services; + +/// +/// Service for managing note naming conventions throughout the system. +/// Centralizes the logic for generating note titles for meeting notes and page notes. +/// +public interface INoteNamingService +{ + /// + /// Gets the title for a meeting note based on the meeting date. + /// Format: "#Meeting Notes MM/dd/yyyy" + /// + /// The date of the meeting + /// The formatted meeting note title + string GetMeetingNoteTitle(DateTime meetingDate); + + /// + /// Gets the title for a page note based on the page identifier. + /// Format: "#{pageIdentifier}" + /// + /// The page identifier (e.g., "students", "teams") + /// The formatted page note title + string GetPageNoteTitle(string pageIdentifier); + + /// + /// Checks if a note title represents a page note (starts with "#"). + /// + /// The note title to check + /// True if the note is a page note, false otherwise + bool IsPageNote(string noteTitle); + + /// + /// Checks if a note title represents a meeting note (starts with "#Meeting Notes"). + /// + /// The note title to check + /// True if the note is a meeting note, false otherwise + bool IsMeetingNote(string noteTitle); +} diff --git a/Core/Services/NoteNamingService.cs b/Core/Services/NoteNamingService.cs new file mode 100644 index 0000000..7ca3ca5 --- /dev/null +++ b/Core/Services/NoteNamingService.cs @@ -0,0 +1,50 @@ +namespace Core.Services; + +/// +/// Implementation of INoteNamingService that provides note naming conventions. +/// Uses "#" as the prefix for page notes and meeting notes. +/// +public class NoteNamingService : INoteNamingService +{ + private const string PageNotePrefix = "#"; + private const string MeetingNotePrefix = "#Meeting Notes"; + + /// + public string GetMeetingNoteTitle(DateTime meetingDate) + { + return $"{MeetingNotePrefix} {meetingDate:MM/dd/yyyy}"; + } + + /// + public string GetPageNoteTitle(string pageIdentifier) + { + if (string.IsNullOrWhiteSpace(pageIdentifier)) + { + throw new ArgumentException("Page identifier cannot be null or empty", nameof(pageIdentifier)); + } + + return $"{PageNotePrefix}{pageIdentifier}"; + } + + /// + public bool IsPageNote(string noteTitle) + { + if (string.IsNullOrWhiteSpace(noteTitle)) + { + return false; + } + + return noteTitle.StartsWith(PageNotePrefix, StringComparison.Ordinal); + } + + /// + public bool IsMeetingNote(string noteTitle) + { + if (string.IsNullOrWhiteSpace(noteTitle)) + { + return false; + } + + return noteTitle.StartsWith(MeetingNotePrefix, StringComparison.Ordinal); + } +} diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 0148c7d..49f191d 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -14,6 +14,7 @@ namespace Data public DbSet Careers { get; set; } public DbSet Notes { get; set; } public DbSet NoteHistories { get; set; } + public DbSet TeamMeetingHistories { get; set; } public AppDbContext() { diff --git a/Data/Configurations/TeamMeetingHistoryConfiguration.cs b/Data/Configurations/TeamMeetingHistoryConfiguration.cs new file mode 100644 index 0000000..e9ce9c2 --- /dev/null +++ b/Data/Configurations/TeamMeetingHistoryConfiguration.cs @@ -0,0 +1,29 @@ +using Core.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Data.Configurations +{ + public class TeamMeetingHistoryConfiguration : IEntityTypeConfiguration + { + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(tmh => tmh.Id); + + // Indexes + builder.HasIndex(tmh => tmh.MeetingDate); + + // Constraints + builder.Property(tmh => tmh.MeetingDate) + .IsRequired(); + + builder.HasMany(tmh => tmh.Teams) + .WithMany() + .UsingEntity(j => j.ToTable("TeamMeetingHistoryTeams")); + + builder.HasMany(tmh => tmh.Students) + .WithMany() + .UsingEntity(j => j.ToTable("TeamMeetingHistoryStudents")); + } + } +} diff --git a/Data/Migrations/20260120024048_AddTeamMeetingHistory.Designer.cs b/Data/Migrations/20260120024048_AddTeamMeetingHistory.Designer.cs new file mode 100644 index 0000000..0332f9a --- /dev/null +++ b/Data/Migrations/20260120024048_AddTeamMeetingHistory.Designer.cs @@ -0,0 +1,567 @@ +// +using System; +using Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260120024048_AddTeamMeetingHistory")] + partial class AddTeamMeetingHistory + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + + modelBuilder.Entity("CareerEventDefinition", b => + { + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("RelatedCareersId") + .HasColumnType("INTEGER"); + + b.HasKey("EventDefinitionId", "RelatedCareersId"); + + b.HasIndex("RelatedCareersId"); + + b.ToTable("EventDefinitionCareers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.Career", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Careers"); + }); + + modelBuilder.Entity("Core.Entities.EventDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterEligibilityCountRegionals") + .HasColumnType("INTEGER"); + + b.Property("ChapterEligibilityCountState") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Documentation") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Eligibility") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("EventFormat") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("LevelOfEffort") + .HasColumnType("INTEGER"); + + b.Property("MaxTeamSize") + .HasColumnType("INTEGER"); + + b.Property("MinTeamSize") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("OnSiteActivity") + .HasColumnType("INTEGER"); + + b.Property("Presubmission") + .HasColumnType("INTEGER"); + + b.Property("SemifinalistActivity") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ShortName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Theme") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventFormat"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Core.Entities.EventOccurrence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Date") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("Location") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SpecialEventType") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("Time") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EventDefinitionId"); + + b.HasIndex("SpecialEventType"); + + b.HasIndex("StartTime"); + + b.ToTable("EventOccurrences"); + }); + + modelBuilder.Entity("Core.Entities.Note", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("IsPinned") + .HasColumnType("INTEGER"); + + b.Property("LastModifiedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("IsPinned"); + + b.HasIndex("Title"); + + b.ToTable("Notes"); + }); + + modelBuilder.Entity("Core.Entities.NoteHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChangeType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("TEXT"); + + b.Property("ModifiedBy") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("NoteId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ModifiedAt"); + + b.HasIndex("NoteId"); + + b.HasIndex("NoteId", "ModifiedAt"); + + b.ToTable("NoteHistories"); + }); + + modelBuilder.Entity("Core.Entities.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Grade") + .HasColumnType("INTEGER"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("NationalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("OfficerRole") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("RegionalId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("StateId") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("TsaYear") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.HasIndex("Grade"); + + b.HasIndex("FirstName", "LastName"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Core.Entities.StudentEventRanking", b => + { + b.Property("StudentId") + .HasColumnType("INTEGER"); + + b.Property("EventDefinitionId") + .HasColumnType("INTEGER"); + + b.Property("Rank") + .HasColumnType("INTEGER"); + + b.HasKey("StudentId", "EventDefinitionId"); + + b.HasIndex("EventDefinitionId"); + + b.HasIndex("Rank"); + + b.HasIndex("StudentId"); + + b.ToTable("StudentEventRanking"); + }); + + modelBuilder.Entity("Core.Entities.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CaptainId") + .HasColumnType("INTEGER"); + + b.Property("EventId") + .HasColumnType("INTEGER"); + + b.Property("Identifier") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CaptainId"); + + b.HasIndex("EventId"); + + b.HasIndex("EventId", "Identifier"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MeetingDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MeetingDate"); + + b.ToTable("TeamMeetingHistories"); + }); + + modelBuilder.Entity("StudentTeam", b => + { + b.Property("StudentsId") + .HasColumnType("INTEGER"); + + b.Property("TeamsId") + .HasColumnType("INTEGER"); + + b.HasKey("StudentsId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamStudents", (string)null); + }); + + modelBuilder.Entity("StudentTeamMeetingHistory", b => + { + b.Property("StudentsId") + .HasColumnType("INTEGER"); + + b.Property("TeamMeetingHistoryId") + .HasColumnType("INTEGER"); + + b.HasKey("StudentsId", "TeamMeetingHistoryId"); + + b.HasIndex("TeamMeetingHistoryId"); + + b.ToTable("TeamMeetingHistoryStudents", (string)null); + }); + + modelBuilder.Entity("TeamTeamMeetingHistory", b => + { + b.Property("TeamMeetingHistoryId") + .HasColumnType("INTEGER"); + + b.Property("TeamsId") + .HasColumnType("INTEGER"); + + b.HasKey("TeamMeetingHistoryId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamMeetingHistoryTeams", (string)null); + }); + + modelBuilder.Entity("CareerEventDefinition", b => + { + b.HasOne("Core.Entities.EventDefinition", null) + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Career", null) + .WithMany() + .HasForeignKey("RelatedCareersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.EventOccurrence", b => + { + b.HasOne("Core.Entities.EventDefinition", "EventDefinition") + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("EventDefinition"); + }); + + modelBuilder.Entity("Core.Entities.NoteHistory", b => + { + b.HasOne("Core.Entities.Note", "Note") + .WithMany("NoteHistories") + .HasForeignKey("NoteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Note"); + }); + + modelBuilder.Entity("Core.Entities.StudentEventRanking", b => + { + b.HasOne("Core.Entities.EventDefinition", "EventDefinition") + .WithMany() + .HasForeignKey("EventDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Student", "Student") + .WithMany("EventRankings") + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EventDefinition"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Core.Entities.Team", b => + { + b.HasOne("Core.Entities.Student", "Captain") + .WithMany() + .HasForeignKey("CaptainId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.EventDefinition", "Event") + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Captain"); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("StudentTeam", b => + { + b.HasOne("Core.Entities.Student", null) + .WithMany() + .HasForeignKey("StudentsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("StudentTeamMeetingHistory", b => + { + b.HasOne("Core.Entities.Student", null) + .WithMany() + .HasForeignKey("StudentsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.TeamMeetingHistory", null) + .WithMany() + .HasForeignKey("TeamMeetingHistoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeamTeamMeetingHistory", b => + { + b.HasOne("Core.Entities.TeamMeetingHistory", null) + .WithMany() + .HasForeignKey("TeamMeetingHistoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.Note", b => + { + b.Navigation("NoteHistories"); + }); + + modelBuilder.Entity("Core.Entities.Student", b => + { + b.Navigation("EventRankings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260120024048_AddTeamMeetingHistory.cs b/Data/Migrations/20260120024048_AddTeamMeetingHistory.cs new file mode 100644 index 0000000..f03e29d --- /dev/null +++ b/Data/Migrations/20260120024048_AddTeamMeetingHistory.cs @@ -0,0 +1,104 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// + public partial class AddTeamMeetingHistory : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TeamMeetingHistories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + MeetingDate = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeamMeetingHistories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TeamMeetingHistoryStudents", + columns: table => new + { + StudentsId = table.Column(type: "INTEGER", nullable: false), + TeamMeetingHistoryId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeamMeetingHistoryStudents", x => new { x.StudentsId, x.TeamMeetingHistoryId }); + table.ForeignKey( + name: "FK_TeamMeetingHistoryStudents_Students_StudentsId", + column: x => x.StudentsId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TeamMeetingHistoryStudents_TeamMeetingHistories_TeamMeetingHistoryId", + column: x => x.TeamMeetingHistoryId, + principalTable: "TeamMeetingHistories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TeamMeetingHistoryTeams", + columns: table => new + { + TeamMeetingHistoryId = table.Column(type: "INTEGER", nullable: false), + TeamsId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TeamMeetingHistoryTeams", x => new { x.TeamMeetingHistoryId, x.TeamsId }); + table.ForeignKey( + name: "FK_TeamMeetingHistoryTeams_TeamMeetingHistories_TeamMeetingHistoryId", + column: x => x.TeamMeetingHistoryId, + principalTable: "TeamMeetingHistories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TeamMeetingHistoryTeams_Teams_TeamsId", + column: x => x.TeamsId, + principalTable: "Teams", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TeamMeetingHistories_MeetingDate", + table: "TeamMeetingHistories", + column: "MeetingDate"); + + migrationBuilder.CreateIndex( + name: "IX_TeamMeetingHistoryStudents_TeamMeetingHistoryId", + table: "TeamMeetingHistoryStudents", + column: "TeamMeetingHistoryId"); + + migrationBuilder.CreateIndex( + name: "IX_TeamMeetingHistoryTeams_TeamsId", + table: "TeamMeetingHistoryTeams", + column: "TeamsId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TeamMeetingHistoryStudents"); + + migrationBuilder.DropTable( + name: "TeamMeetingHistoryTeams"); + + migrationBuilder.DropTable( + name: "TeamMeetingHistories"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 2adc966..4d4704b 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -370,6 +370,22 @@ namespace Data.Migrations b.ToTable("Teams"); }); + modelBuilder.Entity("Core.Entities.TeamMeetingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("MeetingDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MeetingDate"); + + b.ToTable("TeamMeetingHistories"); + }); + modelBuilder.Entity("StudentTeam", b => { b.Property("StudentsId") @@ -385,6 +401,36 @@ namespace Data.Migrations b.ToTable("TeamStudents", (string)null); }); + modelBuilder.Entity("StudentTeamMeetingHistory", b => + { + b.Property("StudentsId") + .HasColumnType("INTEGER"); + + b.Property("TeamMeetingHistoryId") + .HasColumnType("INTEGER"); + + b.HasKey("StudentsId", "TeamMeetingHistoryId"); + + b.HasIndex("TeamMeetingHistoryId"); + + b.ToTable("TeamMeetingHistoryStudents", (string)null); + }); + + modelBuilder.Entity("TeamTeamMeetingHistory", b => + { + b.Property("TeamMeetingHistoryId") + .HasColumnType("INTEGER"); + + b.Property("TeamsId") + .HasColumnType("INTEGER"); + + b.HasKey("TeamMeetingHistoryId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamMeetingHistoryTeams", (string)null); + }); + modelBuilder.Entity("CareerEventDefinition", b => { b.HasOne("Core.Entities.EventDefinition", null) @@ -473,6 +519,36 @@ namespace Data.Migrations .IsRequired(); }); + modelBuilder.Entity("StudentTeamMeetingHistory", b => + { + b.HasOne("Core.Entities.Student", null) + .WithMany() + .HasForeignKey("StudentsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.TeamMeetingHistory", null) + .WithMany() + .HasForeignKey("TeamMeetingHistoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeamTeamMeetingHistory", b => + { + b.HasOne("Core.Entities.TeamMeetingHistory", null) + .WithMany() + .HasForeignKey("TeamMeetingHistoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Core.Entities.Note", b => { b.Navigation("NoteHistories"); diff --git a/WebApp/Components/Features/MeetingSchedule/History.razor b/WebApp/Components/Features/MeetingSchedule/History.razor new file mode 100644 index 0000000..bafde1c --- /dev/null +++ b/WebApp/Components/Features/MeetingSchedule/History.razor @@ -0,0 +1,282 @@ +@page "/meeting-schedule/history" +@attribute [Authorize] +@using Core.Entities +@using Microsoft.EntityFrameworkCore +@using WebApp.Components.Shared.Components +@using WebApp.Services +@inject ITeamMeetingHistoryService TeamMeetingHistoryService +@inject AppDbContext Context +@inject IDialogService DialogService +@inject ISnackbar Snackbar +@inject IConfiguration Configuration +@implements IAsyncDisposable + + + + + Back to Schedule + + + + + + @if (_isLoading) + { + + } + else + { + + @if (_meetingHistories.Any()) + { + + + + + @foreach (var team in _allTeams) + { + + } + + + + + Date + + + @{ + var teamIndex = 0; + } + @foreach (var team in _allTeams) + { + var isEven = teamIndex % 2 == 0; + var bgColor = isEven ? "background-color: var(--mud-palette-background-grey);" : ""; + var headerStyle = $"padding: 4px 2px; border-right: 1px solid var(--mud-palette-divider); {bgColor}"; + + + @team.ToString() + + + teamIndex++; + } + + + + + @context.MeetingDate.ToString("MM/dd/yy") + + + @{ + var rowTeamIndex = 0; + } + @foreach (var team in _allTeams) + { + var met = TeamMetOnDate(context, team); + var isEven = rowTeamIndex % 2 == 0; + var bgColor = isEven ? "background-color: var(--mud-palette-background-grey);" : ""; + var cellStyle = $"padding: 4px 2px; border-right: 1px solid var(--mud-palette-divider); {bgColor}"; + + @if (met) + { + × + } + + rowTeamIndex++; + } + + + + Times Met + + @{ + var footerTeamIndex = 0; + } + @foreach (var team in _allTeams) + { + var timesMet = GetTimesMetForTeam(team); + var isEven = footerTeamIndex % 2 == 0; + var bgColor = isEven ? "background-color: var(--mud-palette-background-grey);" : ""; + var footerCellStyle = $"padding: 4px 2px; border-right: 1px solid var(--mud-palette-divider); {bgColor}"; + + @timesMet + + footerTeamIndex++; + } + + + + } + else + { + No meeting history found. Save a meeting schedule to get started. + } + + } + + +@code { + private List _meetingHistories = []; + private List _allTeams = []; + private Dictionary _timesMetDict = new(); + private bool _isLoading = true; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await LoadData(); + } + + private async Task LoadData() + { + if (_isDisposed) return; + + try + { + _isLoading = true; + StateHasChanged(); + + // Load all teams for columns + _allTeams = await Context.Teams + .AsNoTracking() + .Include(t => t.Event) + .OrderBy(t => t.Event.Name) + .ThenBy(t => t.Identifier) + .ToListAsync(); + + // Load meeting histories + await RefreshMeetingHistories(); + + // Calculate times met + CalculateTimesMet(); + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private async Task RefreshMeetingHistories() + { + _meetingHistories = (await TeamMeetingHistoryService.GetMeetingHistoriesAsync()).ToList(); + + // Extract all unique teams from meeting histories and merge with all teams + var teamsInHistory = _meetingHistories + .SelectMany(mh => mh.Teams) + .DistinctBy(t => t.Id) + .ToList(); + + // Merge with all teams, ensuring all teams are included + var allTeamIds = _allTeams.Select(t => t.Id).ToHashSet(); + var newTeams = teamsInHistory.Where(t => !allTeamIds.Contains(t.Id)).ToList(); + _allTeams = _allTeams.Concat(newTeams).OrderBy(t => t.Event?.Name ?? "").ThenBy(t => t.Identifier).ToList(); + } + + private void CalculateTimesMet() + { + _timesMetDict.Clear(); + + foreach (var team in _allTeams) + { + _timesMetDict[team.Id] = 0; + } + + foreach (var history in _meetingHistories) + { + var teamIds = history.Teams.Select(t => t.Id).ToHashSet(); + foreach (var teamId in teamIds) + { + if (_timesMetDict.ContainsKey(teamId)) + { + _timesMetDict[teamId]++; + } + } + } + } + + private int GetTimesMetForTeam(Team team) + { + return _timesMetDict.GetValueOrDefault(team.Id, 0); + } + + private bool TeamMetOnDate(TeamMeetingHistory history, Team team) + { + return history.Teams.Any(t => t.Id == team.Id); + } + + private async Task ViewMeetingDetails(TeamMeetingHistory history) + { + var parameters = new DialogParameters + { + ["MeetingHistoryId"] = history.Id + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Large, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Meeting Details", parameters, options); + var result = await dialog.Result; + + if (!result.Canceled) + { + // Refresh data if meeting was updated or deleted + await RefreshMeetingHistories(); + CalculateTimesMet(); + if (!_isDisposed) + { + StateHasChanged(); + } + } + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Features/MeetingSchedule/Index.razor b/WebApp/Components/Features/MeetingSchedule/Index.razor index a80ae52..1838376 100644 --- a/WebApp/Components/Features/MeetingSchedule/Index.razor +++ b/WebApp/Components/Features/MeetingSchedule/Index.razor @@ -4,14 +4,34 @@ @using Core.Calculation @using Microsoft.EntityFrameworkCore @using WebApp.Components.Shared.Components +@using WebApp.Components.Features.MeetingSchedule @using Core.Utility @inject IConfiguration Configuration @inject AppDbContext Context @inject ClipboardService ClipboardService @inject LocalStorageService LocalStorage +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + View History + + + + + Save as History + + @@ -671,6 +691,29 @@ } } + private async Task OpenSaveHistoryDialog() + { + var parameters = new DialogParameters + { + ["ScheduledTeams"] = _scheduledTeams, + ["AbsentStudents"] = _absentStudents, + ["AllTeams"] = _teams, + ["AllStudents"] = _students + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Save Meeting History", parameters, options); + var result = await dialog.Result; + + // Note: Success message is already shown in the dialog, no need to show another here + } + private void AppendScheduledTeams(StringBuilder sb, TeamScheduleTimeSlot timeslot) { var timeSlotIndex = GetTimeSlotIndex(timeslot.Name); diff --git a/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor b/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor new file mode 100644 index 0000000..d127d3d --- /dev/null +++ b/WebApp/Components/Features/MeetingSchedule/MeetingHistoryDetailDialog.razor @@ -0,0 +1,298 @@ +@namespace WebApp.Components.Features.MeetingSchedule +@using Core.Entities +@using Core.Services +@using Core.Utility +@using WebApp.Services +@using WebApp.Components.Shared.Components +@using WebApp.Models +@inject ITeamMeetingHistoryService TeamMeetingHistoryService +@inject INotesService NotesService +@inject INoteNamingService NoteNamingService +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@implements IAsyncDisposable + + + + @if (_isLoading) + { + + } + else if (_meetingHistory == null) + { + Meeting history not found. + } + else + { + + Meeting Details + + + + + Date: @_meetingHistory.MeetingDate.ToString("MM/dd/yyyy") + + + + + + + Teams That Met (@_meetingHistory.Teams.Count) + + + @foreach (var team in _meetingHistory.Teams.OrderByEventFormatFirst().ThenBy(e => e.ToString())) + { + @team.ToString() + } + + + + + + Students (@GetAllStudentsFromTeams().Count) + + + @{ + var presentStudentIds = _meetingHistory.Students.Select(s => s.Id).ToHashSet(); + var allStudents = GetAllStudentsFromTeams().OrderBy(s => s.FirstName); + } + @foreach (var student in allStudents) + { + var isPresent = presentStudentIds.Contains(student.Id); + + @student.FirstNameLastName + + } + + + + @if (_meetingNote != null) + { + + Meeting Notes + + @_meetingNote.Title + @if (!string.IsNullOrWhiteSpace(_meetingNote.Content)) + { + + @((MarkupString)MarkdownHelper.ToHtml(_meetingNote.Content)) + + } + + Edit Note + + + } + + } + + + @if (_meetingHistory != null) + { + + Delete + + + Close + } + else + { + + Close + } + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public int MeetingHistoryId { get; set; } + + private TeamMeetingHistory? _meetingHistory; + private Note? _meetingNote; + private bool _isLoading = true; + private bool _isDeleting = false; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + private bool IsActionDisabled => _isLoading || _isDeleting; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await LoadMeetingHistory(); + } + + private async Task LoadMeetingHistory() + { + if (_isDisposed) return; + + try + { + _meetingHistory = await TeamMeetingHistoryService.GetMeetingHistoryAsync(MeetingHistoryId); + + // Load note by title if meeting history exists + if (_meetingHistory != null) + { + _meetingNote = await TeamMeetingHistoryService.GetMeetingNoteAsync(_meetingHistory.MeetingDate); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading meeting history: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private async Task ConfirmDelete() + { + if (_isDisposed || _isDeleting || _meetingHistory == null) return; + + var result = await DialogService.ShowMessageBox( + "Confirm Delete", + $"Are you sure you want to delete the meeting history for {_meetingHistory.MeetingDate:MM/dd/yyyy}? This action cannot be undone.", + yesText: "Delete", + cancelText: "Cancel"); + + if (result == true) + { + await DeleteMeetingHistory(); + } + } + + private async Task DeleteMeetingHistory() + { + if (_isDisposed || _isDeleting || _meetingHistory == null) return; + + try + { + _isDeleting = true; + StateHasChanged(); + + await TeamMeetingHistoryService.DeleteMeetingHistoryAsync(MeetingHistoryId); + + if (!_isDisposed) + { + Snackbar.Add("Meeting history deleted successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error deleting meeting history: {ex.Message}", Severity.Error); + _isDeleting = false; + StateHasChanged(); + } + } + } + + private async Task ViewNote() + { + if (_meetingNote == null) return; + + var parameters = new DialogParameters + { + ["NoteId"] = _meetingNote.Id + }; + + var options = new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + + var dialog = await DialogService.ShowAsync("Meeting Notes", parameters, options); + await dialog.Result; + + // Refresh meeting history to get updated note + await LoadMeetingHistory(); + } + + private List GetAllStudentsFromTeams() + { + if (_meetingHistory == null) + return []; + + var allStudents = new List(); + var studentIds = new HashSet(); + + foreach (var team in _meetingHistory.Teams) + { + foreach (var student in team.Students) + { + if (!studentIds.Contains(student.Id)) + { + studentIds.Add(student.Id); + allStudents.Add(student); + } + } + } + + return allStudents; + } + + private void Close() + { + if (_isDisposed) return; + MudDialog.Close(); + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Features/MeetingSchedule/SaveMeetingHistoryDialog.razor b/WebApp/Components/Features/MeetingSchedule/SaveMeetingHistoryDialog.razor new file mode 100644 index 0000000..8a1fb3d --- /dev/null +++ b/WebApp/Components/Features/MeetingSchedule/SaveMeetingHistoryDialog.razor @@ -0,0 +1,317 @@ +@namespace WebApp.Components.Features.MeetingSchedule +@using Core.Entities +@using Core.Calculation +@using Core.Services +@using WebApp.Services +@using WebApp.Components.Shared.Components +@using WebApp.Models +@inject ITeamMeetingHistoryService TeamMeetingHistoryService +@inject INotesService NotesService +@inject INoteNamingService NoteNamingService +@inject ISnackbar Snackbar +@implements IAsyncDisposable + + + + @if (_isLoading) + { + + } + else + { + + Save Meeting History + + + + + + Teams That Met + + + @foreach (var team in AllTeams.OrderByEventFormatFirst().ThenBy(e => e.ToString())) + { + + @team.ToString() + + } + + + + + + Students Present + + + @foreach (var student in AllStudents.OrderBy(s => s.FirstName)) + { + + @student.FirstNameLastName + + } + + + + + + + + + + + + + + + } + + + Cancel + + @if (_isSaving) + { + + Saving... + } + else + { + Save + } + + + + +@code { + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public IEnumerable ScheduledTeams { get; set; } = []; + + [Parameter] + public IEnumerable AbsentStudents { get; set; } = []; + + [Parameter] + public IEnumerable AllTeams { get; set; } = []; + + [Parameter] + public IEnumerable AllStudents { get; set; } = []; + + private DateTime? _meetingDate = DateTime.Today; + private List _selectedTeams = []; + private List _selectedStudents = []; + private string _noteTitle = ""; + private string _noteContent = ""; + private bool _isLoading = true; + private bool _isSaving = false; + private CancellationTokenSource? _cancellationTokenSource; + private bool _isDisposed = false; + + protected override void OnInitialized() + { + _cancellationTokenSource = new CancellationTokenSource(); + } + + protected override async Task OnInitializedAsync() + { + await LoadInitialData(); + } + + private async Task LoadInitialData() + { + if (_isDisposed) return; + + try + { + // Initialize selected teams from scheduled teams + _selectedTeams = ScheduledTeams.ToList(); + + // Initialize selected students (all students except absent ones) + var absentStudentIds = AbsentStudents.Select(s => s.Id).ToHashSet(); + _selectedStudents = AllStudents.Where(s => !absentStudentIds.Contains(s.Id)).ToList(); + + // Generate default note title if meeting date is set + if (_meetingDate.HasValue) + { + _noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error loading data: {ex.Message}", Severity.Error); + } + } + finally + { + if (!_isDisposed) + { + _isLoading = false; + StateHasChanged(); + } + } + } + + private bool IsSaveDisabled => _isSaving || _isLoading; + + protected override async Task OnParametersSetAsync() + { + if (_meetingDate.HasValue && string.IsNullOrEmpty(_noteTitle)) + { + _noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value); + } + await base.OnParametersSetAsync(); + } + + private async Task OnMeetingDateChanged(DateTime? date) + { + _meetingDate = date; + if (date.HasValue) + { + _noteTitle = NoteNamingService.GetMeetingNoteTitle(date.Value); + } + await InvokeAsync(StateHasChanged); + } + + private void OnTeamsChanged(IEnumerable teams) + { + _selectedTeams = teams.ToList(); + StateHasChanged(); + } + + private void OnStudentsChanged(IEnumerable students) + { + _selectedStudents = students.ToList(); + StateHasChanged(); + } + + private async Task Save() + { + if (_isDisposed || _isSaving) return; + + if (!_meetingDate.HasValue) + { + Snackbar.Add("Please select a meeting date", Severity.Warning); + return; + } + + try + { + _isSaving = true; + StateHasChanged(); + + // Create or update note if note content is provided + Note? note = null; + if (!string.IsNullOrWhiteSpace(_noteContent)) + { + // Use the naming service to get the meeting note title + var noteTitle = NoteNamingService.GetMeetingNoteTitle(_meetingDate.Value); + + // Check if note already exists + var existingNote = await NotesService.GetNotesAsync(includeDeleted: false); + note = existingNote.FirstOrDefault(n => n.Title == noteTitle); + + if (note != null) + { + // Update existing note + note.Content = _noteContent; + note = await NotesService.UpdateNoteAsync(note); + } + else + { + // Create new note + note = new Note + { + Title = noteTitle, + Content = _noteContent + }; + note = await NotesService.CreateNoteAsync(note); + } + } + + // Create meeting history + var meetingHistory = new TeamMeetingHistory + { + MeetingDate = _meetingDate.Value, + Teams = _selectedTeams, + Students = _selectedStudents + }; + + await TeamMeetingHistoryService.CreateMeetingHistoryAsync(meetingHistory); + + if (!_isDisposed) + { + Snackbar.Add($"Meeting history saved for {_meetingDate.Value:MM/dd/yyyy}", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + } + catch (TaskCanceledException) + { + // Component was disposed, ignore + } + catch (JSDisconnectedException) + { + // JS connection lost, ignore + } + catch (Exception ex) + { + if (!_isDisposed) + { + Snackbar.Add($"Error saving meeting history: {ex.Message}", Severity.Error); + _isSaving = false; + StateHasChanged(); + } + } + } + + private void Cancel() + { + if (_isDisposed) return; + MudDialog.Cancel(); + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + } + await ValueTask.CompletedTask; + } +} diff --git a/WebApp/Components/Pages/NoteEditDialog.razor b/WebApp/Components/Pages/NoteEditDialog.razor index 7b1ea86..2178fef 100644 --- a/WebApp/Components/Pages/NoteEditDialog.razor +++ b/WebApp/Components/Pages/NoteEditDialog.razor @@ -1,8 +1,10 @@ @using Core.Entities +@using Core.Services @using WebApp.Services @using PSC.Blazor.Components.MarkdownEditor @using MudBlazor @inject INotesService NotesService +@inject INoteNamingService NoteNamingService @inject ISnackbar Snackbar @inject IDialogService DialogService @inject MarkdownTablePasteService MarkdownTablePasteService @@ -45,7 +47,7 @@ private Note _note = null!; private bool _pasteMarkdownInitialized = false; - private bool IsPageNote => Note?.Title?.StartsWith("@") ?? false; + private bool IsPageNote => Note?.Title != null && NoteNamingService.IsPageNote(Note.Title); protected override void OnInitialized() { diff --git a/WebApp/Components/Pages/Notes.razor b/WebApp/Components/Pages/Notes.razor index 3d6ba05..341eb76 100644 --- a/WebApp/Components/Pages/Notes.razor +++ b/WebApp/Components/Pages/Notes.razor @@ -2,9 +2,11 @@ @attribute [Authorize] @implements IAsyncDisposable @using Core.Entities +@using Core.Services @using WebApp.Services @using WebApp.Components.Shared.Components @inject INotesService NotesService +@inject INoteNamingService NoteNamingService @inject IDialogService DialogService @inject ISnackbar Snackbar @inject NavigationManager NavigationManager @@ -67,7 +69,7 @@ @GetNoteHeaderText(note, isExpanded) - @if (!note.Title.StartsWith("@") && !note.IsDeleted && !isExpanded) + @if (!NoteNamingService.IsPageNote(note.Title) && !note.IsDeleted && !isExpanded) { + @ontouchstart="@HandleTouchStart"> (sp => builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); // State container for maintaining state per user connection (Blazor Server) diff --git a/WebApp/Services/INotesService.cs b/WebApp/Services/INotesService.cs index 8834ed4..1020fca 100644 --- a/WebApp/Services/INotesService.cs +++ b/WebApp/Services/INotesService.cs @@ -19,7 +19,7 @@ public interface INotesService /// Gets a page-specific note by page identifier. /// /// The page identifier (e.g., "Teams", "Registration") - /// The note with title "@{pageIdentifier}" or null if not found + /// The note with title "#{pageIdentifier}" or null if not found Task GetPageNoteAsync(string pageIdentifier); /// diff --git a/WebApp/Services/ITeamMeetingHistoryService.cs b/WebApp/Services/ITeamMeetingHistoryService.cs new file mode 100644 index 0000000..3d43044 --- /dev/null +++ b/WebApp/Services/ITeamMeetingHistoryService.cs @@ -0,0 +1,50 @@ +using Core.Entities; + +namespace WebApp.Services; + +public interface ITeamMeetingHistoryService +{ + /// + /// Gets all meeting histories within an optional date range. + /// + /// Optional start date filter + /// Optional end date filter + Task> GetMeetingHistoriesAsync(DateTime? startDate = null, DateTime? endDate = null); + + /// + /// Gets a specific meeting history by ID with teams and students included. + /// + Task GetMeetingHistoryAsync(int id); + + /// + /// Creates a new meeting history record. + /// + Task CreateMeetingHistoryAsync(TeamMeetingHistory meetingHistory); + + /// + /// Updates an existing meeting history record. + /// + Task UpdateMeetingHistoryAsync(TeamMeetingHistory meetingHistory); + + /// + /// Deletes a meeting history record. + /// + Task DeleteMeetingHistoryAsync(int id); + + /// + /// Gets which teams met on a specific date. + /// + Task> GetTeamsForDateAsync(DateTime date); + + /// + /// Gets which students were present on a specific date. + /// + Task> GetStudentsForDateAsync(DateTime date); + + /// + /// Gets the meeting note for a specific meeting date by looking it up by title. + /// + /// The date of the meeting + /// The note if found, null otherwise + Task GetMeetingNoteAsync(DateTime meetingDate); +} diff --git a/WebApp/Services/NotesService.cs b/WebApp/Services/NotesService.cs index 0adbff5..6c5d844 100644 --- a/WebApp/Services/NotesService.cs +++ b/WebApp/Services/NotesService.cs @@ -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 _logger; + private readonly INoteNamingService _noteNamingService; public NotesService( AppDbContext context, IHttpContextAccessor httpContextAccessor, - ILogger logger) + ILogger 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 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(); } diff --git a/WebApp/Services/TeamMeetingHistoryService.cs b/WebApp/Services/TeamMeetingHistoryService.cs new file mode 100644 index 0000000..704afe7 --- /dev/null +++ b/WebApp/Services/TeamMeetingHistoryService.cs @@ -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 _logger; + private readonly INotesService _notesService; + private readonly INoteNamingService _noteNamingService; + + public TeamMeetingHistoryService( + AppDbContext context, + IHttpContextAccessor httpContextAccessor, + ILogger 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> 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 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 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 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); + } + + /// + /// Gets the meeting note for a specific meeting date by looking it up by title. + /// + /// The date of the meeting + /// The note if found, null otherwise + public async Task 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> 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> GetStudentsForDateAsync(DateTime date) + { + var normalizedDate = date.Date; + return await _context.TeamMeetingHistories + .AsNoTracking() + .Where(tmh => tmh.MeetingDate == normalizedDate) + .SelectMany(tmh => tmh.Students) + .Distinct() + .ToListAsync(); + } +}