using System.Text.Json; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Criteria; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.GenerateAutoNumber; using GB5Shared.Resource.Response; using GB5Shared.Telemetry; using ECPBLL.Meeting.Interfaces; using ECPDAL.Meeting.DTOs; using ECPDAL.Meeting.Interfaces; using Microsoft.Extensions.Logging; namespace ECPBLL.Meeting.Implementations; public class MeetingBLL( IMeetingDAL _dal, AutoNumber _autoNumber, KeyInvalidate _keyInvalidate, EventLogPublish _eventLog, ILogger _logger ) : IMeetingBLL { // PropertyNamingPolicy left at its default (preserve declared PascalCase names) -- see // Communication/CommunicationBLL/Implementations/CommentThreadBLL.cs for why this override // was removed. private static readonly JsonSerializerOptions JsonOpts = new(); // ── GET ─────────────────────────────────────────────────────────── public async Task GetMeetingAsync(int meetingId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-meeting", new { meetingId }); var meeting = await _dal.GetMeetingAsync(meetingId, login, ct).ConfigureAwait(false); if (meeting is null) throw new InvalidOperationException($"Meeting {meetingId} not found."); meeting.Participants = await _dal.GetParticipantsForMeetingAsync(meetingId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(meeting, JsonOpts); } public async Task GetSelectListMeetingAsync(LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-selectlist-meeting-type", new { }); // Dynamic — no hardcoded GCMTYPEID. Resolves (and auto-seeds on first use in this // environment) the "Meeting Type" MGCMTYPE row, then reads its MGCM child rows. var gcmTypeId = await _dal.EnsureMeetingTypeGcmTypeIdAsync(login, ct).ConfigureAwait(false); var list = await _dal.GetSelectListMeetingTypeAsync(gcmTypeId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list, JsonOpts); } public async Task GetMeetingListAsync(CriteriaDTO? criteriaDTO, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-meeting-list", new { criteriaDTO?.SectionCriteriaList?.Count }); var list = await _dal.GetMeetingListAsync(criteriaDTO, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list, JsonOpts); } // ── SAVE (create or update) ──────────────────────────────────────── public async Task SaveMeetingAsync(SaveMeetingDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-meeting", new { dto.MeetingId, dto.Title }); if (string.IsNullOrWhiteSpace(dto.Title)) throw new InvalidOperationException("Title is required."); if (dto.EndOn < dto.StartOn) throw new InvalidOperationException("EndOn cannot be before StartOn."); try { var isNew = dto.MeetingId == 0; if (isNew) { var autoNumber = await _autoNumber.GetNumberAsync(1, "MEETING", login).ConfigureAwait(false); var newId = autoNumber.StartNumber; var entity = new MeetingDTO { MeetingId = newId, Title = dto.Title, MeetingTypeId = dto.MeetingTypeId, StartOn = dto.StartOn, EndOn = dto.EndOn, VirtualLink = dto.VirtualLink, Location = dto.Location, OrganizerUserId = login.UserId, Status = MeetingConstants.MeetingStatus.Scheduled, ObjectTypeId = dto.ObjectTypeId, ObjectId = dto.ObjectId, OuId = dto.OuId, TenantId = login.ClientId, DatabaseName = login.DatabaseName, DatabaseType = login.DatabaseType, Version = 0, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, }; GB5Trace.Step("save-meeting", new { newId, isNew }); await _dal.SaveMeetingAsync(entity, login, ct).ConfigureAwait(false); // The organizer is always the meeting's first participant (implicitly accepted). var participantAutoNumber = await _autoNumber.GetNumberAsync(1, "MEETINGPARTICIPANT", login).ConfigureAwait(false); await _dal.AddParticipantAsync(new MeetingParticipantDTO { MeetingParticipantId = participantAutoNumber.StartNumber, MeetingId = newId, UserId = login.UserId, AttendanceStatus = MeetingConstants.AttendanceStatus.Accepted, TenantId = login.ClientId, DatabaseName = login.DatabaseName, DatabaseType = login.DatabaseType, Status = MeetingConstants.RowStatus.Active, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, }, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.MEETINGCREATEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Meeting Created", entity, Constant.EventTypeConstant.MEETINGCREATEDEVENTTYPEID, newId, login, ct: ct) .ConfigureAwait(false); await InvalidateListCacheAsync(login).ConfigureAwait(false); return $"{SuccessResponse.SaveSuccessMessage} {newId}"; } else { var existing = await _dal.GetMeetingAsync(dto.MeetingId, login, ct).ConfigureAwait(false); if (existing is null) throw new InvalidOperationException($"Meeting {dto.MeetingId} not found."); if (existing.OrganizerUserId != login.UserId) throw new UnauthorizedAccessException("Only the organizer can update this meeting."); existing.Title = dto.Title; existing.MeetingTypeId = dto.MeetingTypeId; existing.StartOn = dto.StartOn; existing.EndOn = dto.EndOn; existing.VirtualLink = dto.VirtualLink; existing.Location = dto.Location; existing.ObjectTypeId = dto.ObjectTypeId; existing.ObjectId = dto.ObjectId; existing.OuId = dto.OuId; existing.ModifiedById = login.UserId; existing.ModifiedOn = DateTime.UtcNow; GB5Trace.Step("save-meeting", new { dto.MeetingId, isNew }); await _dal.UpdateMeetingAsync(existing, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.MEETINGUPDATEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Meeting Updated", existing, Constant.EventTypeConstant.MEETINGUPDATEDEVENTTYPEID, dto.MeetingId, login, ct: ct) .ConfigureAwait(false); await InvalidateListCacheAsync(login).ConfigureAwait(false); await InvalidateMeetingDetailCacheAsync(dto.MeetingId, login).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } } catch (Exception ex) { GB5Trace.MarkFailed("save-meeting-failed", ex); _logger.LogError(ex, "SaveMeeting failed for MeetingId {MeetingId}", dto.MeetingId); throw; } } // ── STATUS ──────────────────────────────────────────────────────── public async Task UpdateMeetingStatusAsync(UpdateMeetingStatusDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-meeting-status", new { dto.MeetingId, dto.NewStatus }); if (dto.NewStatus is < 1 or > 3) throw new InvalidOperationException("NewStatus must be 1 (scheduled), 2 (completed), or 3 (cancelled)."); try { var meeting = await _dal.GetMeetingAsync(dto.MeetingId, login, ct).ConfigureAwait(false); if (meeting is null) throw new InvalidOperationException($"Meeting {dto.MeetingId} not found."); if (meeting.OrganizerUserId != login.UserId) throw new UnauthorizedAccessException("Only the organizer can change this meeting's status."); GB5Trace.Step("save-meeting-status", new { dto.MeetingId, dto.NewStatus }); await _dal.UpdateMeetingStatusAsync(dto.MeetingId, dto.NewStatus, login.UserId, login, ct) .ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.MEETINGSTATUSCHANGEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Meeting Status Changed", dto, Constant.EventTypeConstant.MEETINGSTATUSCHANGEDEVENTTYPEID, dto.MeetingId, login, ct: ct) .ConfigureAwait(false); await InvalidateListCacheAsync(login).ConfigureAwait(false); await InvalidateMeetingDetailCacheAsync(dto.MeetingId, login).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { GB5Trace.MarkFailed("update-meeting-status-failed", ex); _logger.LogError(ex, "UpdateMeetingStatus failed for MeetingId {MeetingId}", dto.MeetingId); throw; } } // ── DELETE (cancel) ───────────────────────────────────────────────── public async Task DeleteMeetingAsync(int meetingId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("delete-meeting", new { meetingId }); try { var meeting = await _dal.GetMeetingAsync(meetingId, login, ct).ConfigureAwait(false); if (meeting is null) throw new InvalidOperationException($"Meeting {meetingId} not found."); if (meeting.OrganizerUserId != login.UserId) throw new UnauthorizedAccessException("Only the organizer can delete this meeting."); await _dal.CancelMeetingAsync(meetingId, login.UserId, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.MEETINGDELETEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Meeting Deleted", new { meetingId }, Constant.EventTypeConstant.MEETINGDELETEDEVENTTYPEID, meetingId, login, ct: ct) .ConfigureAwait(false); await InvalidateListCacheAsync(login).ConfigureAwait(false); await InvalidateMeetingDetailCacheAsync(meetingId, login).ConfigureAwait(false); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("delete-meeting-failed", ex); _logger.LogError(ex, "DeleteMeeting failed for MeetingId {MeetingId}", meetingId); throw; } } // ── PARTICIPANTS ────────────────────────────────────────────────── public async Task AddMeetingParticipantAsync(AddMeetingParticipantDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-meeting-participant", new { dto.MeetingId, dto.UserId }); try { var meeting = await _dal.GetMeetingAsync(dto.MeetingId, login, ct).ConfigureAwait(false); if (meeting is null) throw new InvalidOperationException($"Meeting {dto.MeetingId} not found."); if (meeting.OrganizerUserId != login.UserId) throw new UnauthorizedAccessException("Only the organizer can add participants to this meeting."); var existingParticipant = await _dal.GetParticipantAsync(dto.MeetingId, dto.UserId, login, ct).ConfigureAwait(false); if (existingParticipant is not null) throw new InvalidOperationException($"User {dto.UserId} is already a participant of this meeting."); var autoNumber = await _autoNumber.GetNumberAsync(1, "MEETINGPARTICIPANT", login).ConfigureAwait(false); var entity = new MeetingParticipantDTO { MeetingParticipantId = autoNumber.StartNumber, MeetingId = dto.MeetingId, UserId = dto.UserId, AttendanceStatus = MeetingConstants.AttendanceStatus.Invited, TenantId = login.ClientId, DatabaseName = login.DatabaseName, DatabaseType = login.DatabaseType, Status = MeetingConstants.RowStatus.Active, CreatedById = login.UserId, CreatedOn = DateTime.UtcNow, ModifiedById = login.UserId, ModifiedOn = DateTime.UtcNow, }; GB5Trace.Step("save-meeting-participant", new { entity.MeetingParticipantId }); await _dal.AddParticipantAsync(entity, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.MEETINGPARTICIPANTADDEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Meeting Participant Added", entity, Constant.EventTypeConstant.MEETINGPARTICIPANTADDEDEVENTTYPEID, dto.MeetingId, login, ct: ct) .ConfigureAwait(false); await InvalidateParticipantListCacheAsync(dto.MeetingId, login).ConfigureAwait(false); return SuccessResponse.SaveSuccess; } catch (Exception ex) { GB5Trace.MarkFailed("add-meeting-participant-failed", ex); _logger.LogError(ex, "AddMeetingParticipant failed for MeetingId {MeetingId}", dto.MeetingId); throw; } } public async Task UpdateParticipantAttendanceAsync(UpdateParticipantAttendanceDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("validate-participant-attendance", new { dto.MeetingId, dto.UserId, dto.AttendanceStatus }); if (dto.AttendanceStatus is < 1 or > 5) throw new InvalidOperationException("AttendanceStatus must be between 1 (invited) and 5 (no-show)."); try { var meeting = await _dal.GetMeetingAsync(dto.MeetingId, login, ct).ConfigureAwait(false); if (meeting is null) throw new InvalidOperationException($"Meeting {dto.MeetingId} not found."); // The organizer may update anyone's attendance (e.g. marking who attended); // a participant may update only their own (accept/decline the invite). var isSelfUpdate = dto.UserId == login.UserId; if (meeting.OrganizerUserId != login.UserId && !isSelfUpdate) throw new UnauthorizedAccessException("Only the organizer or the participant themselves can update this attendance status."); var participant = await _dal.GetParticipantAsync(dto.MeetingId, dto.UserId, login, ct).ConfigureAwait(false); if (participant is null) throw new InvalidOperationException($"User {dto.UserId} is not a participant of meeting {dto.MeetingId}."); GB5Trace.Step("save-participant-attendance", new { dto.MeetingId, dto.UserId, dto.AttendanceStatus }); await _dal.UpdateParticipantAttendanceAsync(dto.MeetingId, dto.UserId, dto.AttendanceStatus, login.UserId, login, ct) .ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.MEETINGPARTICIPANTADDEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Meeting Participant Attendance Updated", dto, Constant.EventTypeConstant.MEETINGPARTICIPANTADDEDEVENTTYPEID, dto.MeetingId, login, ct: ct) .ConfigureAwait(false); await InvalidateParticipantDetailCacheAsync(dto.MeetingId, dto.UserId, login).ConfigureAwait(false); await InvalidateParticipantListCacheAsync(dto.MeetingId, login).ConfigureAwait(false); return SuccessResponse.UpdateSuccess; } catch (Exception ex) { GB5Trace.MarkFailed("update-participant-attendance-failed", ex); _logger.LogError(ex, "UpdateParticipantAttendance failed for MeetingId {MeetingId}", dto.MeetingId); throw; } } public async Task RemoveMeetingParticipantAsync(int meetingId, int userId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("remove-meeting-participant", new { meetingId, userId }); try { var meeting = await _dal.GetMeetingAsync(meetingId, login, ct).ConfigureAwait(false); if (meeting is null) throw new InvalidOperationException($"Meeting {meetingId} not found."); var isSelfRemoval = userId == login.UserId; if (meeting.OrganizerUserId != login.UserId && !isSelfRemoval) throw new UnauthorizedAccessException("Only the organizer can remove other participants from this meeting."); if (userId == meeting.OrganizerUserId) throw new InvalidOperationException("The organizer cannot be removed — cancel the meeting instead."); await _dal.RemoveParticipantAsync(meetingId, userId, login, ct).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.MEETINGPARTICIPANTREMOVEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Meeting Participant Removed", new { meetingId, userId }, Constant.EventTypeConstant.MEETINGPARTICIPANTREMOVEDEVENTTYPEID, meetingId, login, ct: ct) .ConfigureAwait(false); await InvalidateParticipantDetailCacheAsync(meetingId, userId, login).ConfigureAwait(false); await InvalidateParticipantListCacheAsync(meetingId, login).ConfigureAwait(false); return SuccessResponse.DeleteSuccessMessage; } catch (Exception ex) { GB5Trace.MarkFailed("remove-meeting-participant-failed", ex); _logger.LogError(ex, "RemoveMeetingParticipant failed for MeetingId {MeetingId}", meetingId); throw; } } public async Task GetMeetingDetailsAsync(int meetingId, int userId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-meeting-details", new { meetingId, userId }); var participant = await _dal.GetParticipantAsync(meetingId, userId, login, ct).ConfigureAwait(false); if (participant is null) throw new InvalidOperationException($"User {userId} is not a participant of meeting {meetingId}."); return JsonSerializer.Serialize(participant, JsonOpts); } public async Task GetMeetingDetailsListAsync(int meetingId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-meeting-details-list", new { meetingId }); var participants = await _dal.GetParticipantsForMeetingAsync(meetingId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(participants, JsonOpts); } public async Task GetSelectListMeetingDetailsAsync(int meetingId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-selectlist-meeting-details", new { meetingId }); var list = await _dal.GetSelectListParticipantAsync(meetingId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list, JsonOpts); } // Clears the OBJECTMEETINGLIST key — every meeting mutation needs this, even a brand-new // meeting with no id yet to cache anything else under. private async Task InvalidateListCacheAsync(LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( login.UserId.ToString(), Constant.EntityConstant.OBJECTMEETINGLIST, Constant.CacheKeyLevel.USER_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } // Clears GetMeeting's own per-id cache — needed by SaveMeeting's update path and // UpdateMeetingStatus/DeleteMeeting, all of which mutate a specific existing meeting. private async Task InvalidateMeetingDetailCacheAsync(int meetingId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( meetingId.ToString(), Constant.EntityConstant.OBJECTMEETING, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } // Clears GetMeetingDetailsList + GetSelectListMeetingDetails (the "invite participant" // picker) — needed whenever the participant roster itself changes (add/remove), as opposed // to just one participant's own attendance value. private async Task InvalidateParticipantListCacheAsync(int meetingId, LoginDTO login) { var keyGen = new CacheKeyGeneration(); var listKey = keyGen.KeyGeneration( meetingId.ToString(), Constant.EntityConstant.OBJECTMEETINGDETAILSLIST, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(listKey).ConfigureAwait(false); var selectListKey = keyGen.KeyGeneration( meetingId.ToString(), Constant.EntityConstant.OBJECTMEETINGDETAILS, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(selectListKey).ConfigureAwait(false); } // Clears one participant's own GetMeetingDetails row — needed by UpdateParticipantAttendance, // which changes exactly that row and nothing else in the roster. private async Task InvalidateParticipantDetailCacheAsync(int meetingId, int userId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( $"{meetingId}:{userId}", Constant.EntityConstant.OBJECTMEETINGDETAILS, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } }