using CRMBLL.Task; using CRMDAL.DTO.Task; using ECPBLL.Meeting.Interfaces; using ECPDAL.Meeting.DTOs; using ECPDAL.Meeting.Interfaces; using GB5Shared.DaprCache; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.GB5Constant; using GB5Shared.Telemetry; using Newtonsoft.Json; namespace ECPBLL.Meeting.Implementations; /// /// The Meeting "Action Items" tab — a thin translation layer over the existing shared TTASK /// entity (CRMBLL.Task.ITaskBLL), per the ECP preview's own framing: "the same underlying task /// record used across GB5, not an ECP-only checklist." Every CRM-only field on TaskDTO /// (BizTransactionType, PartyBranch, Asset, Machine, Allocation, WBS codes, plan/expire dates, /// ...) is defaulted here and never exposed on IMeetingActionItemBLL's own DTOs. /// /// List/SelectList go through IMeetingActionItemDAL (ECP-owned, direct TTASK query) rather than /// ITaskBLL, since ITaskBLL's own list methods (GetSelectListTask/GetTaskReport) don't support /// filtering by ObjectTypeId+ObjectId — see MeetingActionItemQB's doc-comment for the confirmed /// column provenance. /// public class MeetingActionItemBLL( ITaskBLL _taskBll, IMeetingActionItemDAL _dal, KeyInvalidate _keyInvalidate, EventLogPublish _eventLog ) : IMeetingActionItemBLL { // Confirmed against MBIZTRANSACTIONTYPE: BIZTRANSACTIONTYPEID = -1499999993, Code/Name // "Task" — the generic CRM Task BizTransactionType, reused here rather than seeding a // separate "ECP Meeting Action Item" row since it carries no ECP-specific routing/workflow. private const int BizTransactionTypeId_EcpMeetingActionItem = -1499999993; // TaskAssignedToType: 3 = user, per CRM's TaskQB ASSIGNEDTOTYPE join convention // (2=usergroup, 3=user, 4=employee, 5=worktype, 6=contact). private const byte AssignedToType_User = 3; public async Task GetMeetingActionItemAsync(int taskId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-meeting-action-item", new { taskId }); var json = await _taskBll.GetTask(taskId, login).ConfigureAwait(false); var task = JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"Action item {taskId} not found."); return JsonConvert.SerializeObject(ToActionItemDTO(task)); } public async Task GetMeetingActionItemListAsync(int meetingId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-meeting-action-item-list", new { meetingId }); var list = await _dal.GetActionItemsForMeetingAsync(Constant.EntityConstant.OBJECTMEETING, meetingId, login, ct) .ConfigureAwait(false); return JsonConvert.SerializeObject(list); } public async Task GetSelectListMeetingActionItemAsync(int meetingId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("get-selectlist-meeting-action-item", new { meetingId }); var list = await _dal.GetSelectListActionItemsForMeetingAsync(Constant.EntityConstant.OBJECTMEETING, meetingId, login, ct) .ConfigureAwait(false); return JsonConvert.SerializeObject(list); } public async Task SaveMeetingActionItemAsync(SaveMeetingActionItemDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("save-meeting-action-item", new { dto.TaskId, dto.MeetingId }); if (string.IsNullOrWhiteSpace(dto.Title)) throw new InvalidOperationException("Title is required."); TaskDTO task; bool isNew = dto.TaskId == 0; if (isNew) { task = new TaskDTO { TaskId = 0, ObjectTypeId = Constant.EntityConstant.OBJECTMEETING, ObjectId = dto.MeetingId, TaskTaskName = dto.Title, TaskDescription = dto.Title, // TaskDescription is required by ValidateTask; the // ECP preview's task modal has no separate description field. TaskAssignedToType = AssignedToType_User, AssignedToUserId = dto.AssignedTo, ParentTaskId = dto.ParentTaskId, TaskPlanStartOn = DateOnly.FromDateTime(DateTime.UtcNow), TaskPlanEndOn = dto.DueDate, TaskDueOn = dto.DueDate, TaskExpireOn = dto.DueDate, BizTransactionTypeId = BizTransactionTypeId_EcpMeetingActionItem, }; } else { var existingJson = await _taskBll.GetTask(dto.TaskId, login).ConfigureAwait(false); task = JsonConvert.DeserializeObject(existingJson) ?? throw new InvalidOperationException($"Action item {dto.TaskId} not found."); task.TaskTaskName = dto.Title; task.TaskDescription = dto.Title; task.AssignedToUserId = dto.AssignedTo; task.ParentTaskId = dto.ParentTaskId; task.TaskPlanEndOn = dto.DueDate; task.TaskDueOn = dto.DueDate; task.TaskExpireOn = dto.DueDate; } var result = await _taskBll.SaveTask([task], login).ConfigureAwait(false); var eventTypeId = isNew ? Constant.EventTypeConstant.MEETINGACTIONITEMCREATEDEVENTTYPEID : Constant.EventTypeConstant.MEETINGACTIONITEMUPDATEDEVENTTYPEID; GB5Trace.Step("event-publish", new { eventTypeId }); await _eventLog.PublishEventLogAsync( isNew ? "Meeting Action Item Created" : "Meeting Action Item Updated", task, eventTypeId, dto.MeetingId, login, ct: ct) .ConfigureAwait(false); await InvalidateActionItemListCacheAsync(dto.MeetingId, login).ConfigureAwait(false); if (!isNew) await InvalidateActionItemDetailCacheAsync(dto.TaskId, login).ConfigureAwait(false); return result; } public async Task DeleteMeetingActionItemAsync(int taskId, LoginDTO login, CancellationToken ct) { GB5Trace.Step("delete-meeting-action-item", new { taskId }); // Fetched before delete purely to recover MeetingId for cache invalidation — TTASK's // ObjectId (the meeting) isn't passed by the caller, only the TaskId. var existingJson = await _taskBll.GetTask(taskId, login).ConfigureAwait(false); var existing = JsonConvert.DeserializeObject(existingJson); var result = await _taskBll.DeleteTask(taskId, login).ConfigureAwait(false); GB5Trace.Step("event-publish", new { Constant.EventTypeConstant.MEETINGACTIONITEMDELETEDEVENTTYPEID }); await _eventLog.PublishEventLogAsync( "Meeting Action Item Deleted", new { taskId }, Constant.EventTypeConstant.MEETINGACTIONITEMDELETEDEVENTTYPEID, taskId, login, ct: ct) .ConfigureAwait(false); await InvalidateActionItemDetailCacheAsync(taskId, login).ConfigureAwait(false); if (existing is not null) await InvalidateActionItemListCacheAsync(existing.ObjectId, login).ConfigureAwait(false); return result; } public async Task RescheduleMeetingActionItemAsync(RescheduleMeetingActionItemDTO dto, LoginDTO login, CancellationToken ct) { GB5Trace.Step("reschedule-meeting-action-item", new { dto.TaskId, dto.NewDate, dto.Reason }); var json = await _taskBll.GetTask(dto.TaskId, login).ConfigureAwait(false); var task = JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException($"Action item {dto.TaskId} not found."); var previousDueDate = task.TaskDueOn; task.TaskDueOn = dto.NewDate; task.TaskPlanEndOn = dto.NewDate; task.TaskExpireOn = dto.NewDate; var result = await _taskBll.SaveTask([task], login).ConfigureAwait(false); // TTASK has no revision-history column — the reason is preserved only in the event log, // not on the task row itself (see this class's / RescheduleMeetingActionItemDTO's doc-comments). GB5Trace.Step("event-publish", new { dto.TaskId, previousDueDate, dto.NewDate }); await _eventLog.PublishEventLogAsync( "Meeting Action Item Rescheduled", new { dto.TaskId, From = previousDueDate, To = dto.NewDate, dto.Reason }, Constant.EventTypeConstant.MEETINGACTIONITEMRESCHEDULEDEVENTTYPEID, dto.TaskId, login, ct: ct) .ConfigureAwait(false); await InvalidateActionItemDetailCacheAsync(dto.TaskId, login).ConfigureAwait(false); await InvalidateActionItemListCacheAsync(task.ObjectId, login).ConfigureAwait(false); return result; } // Clears GetMeetingActionItemList + GetSelectListMeetingActionItem (both keyed by MeetingId). private async Task InvalidateActionItemListCacheAsync(int meetingId, LoginDTO login) { var keyGen = new CacheKeyGeneration(); var listKey = keyGen.KeyGeneration( meetingId.ToString(), Constant.EntityConstant.OBJECTTASKLIST, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(listKey).ConfigureAwait(false); var selectListKey = keyGen.KeyGeneration( meetingId.ToString(), Constant.EntityConstant.OBJECTTASKSELECTLIST, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(selectListKey).ConfigureAwait(false); } // Clears GetMeetingActionItem's own per-id cache — needed by any mutation on an existing task // (update/delete/reschedule; never the create path, which has no prior cache to clear). private async Task InvalidateActionItemDetailCacheAsync(int taskId, LoginDTO login) { var cacheKey = new CacheKeyGeneration().KeyGeneration( taskId, Constant.EntityConstant.OBJECTTASK, Constant.CacheKeyLevel.CLIENT_LEVEL, login); await _keyInvalidate.InvalidateCache(cacheKey).ConfigureAwait(false); } private static MeetingActionItemDTO ToActionItemDTO(TaskDTO task) => new() { TaskId = task.TaskId, MeetingId = task.ObjectId, Title = task.TaskTaskName, AssignedTo = task.AssignedToUserId, AssignedToName = task.AssignedToUserName, DueDate = task.TaskDueOn, ParentTaskId = task.ParentTaskId, WbsLevel = task.TaskWbcLevel, Status = task.TaskStatus, }; }