using AutoMapper; using Marco.Pms.DataAccess.Data; using Marco.Pms.Model.AttendanceModule; using Marco.Pms.Model.Dtos.Attendance; using Marco.Pms.Model.Employees; using Marco.Pms.Model.Entitlements; using Marco.Pms.Model.Mapper; using Marco.Pms.Model.Utilities; using Marco.Pms.Model.ViewModels.Activities; using Marco.Pms.Model.ViewModels.AttendanceVM; using Marco.Pms.Services.Hubs; using Marco.Pms.Services.Service; using Marco.Pms.Services.Service.ServiceInterfaces; using MarcoBMS.Services.Helpers; using MarcoBMS.Services.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.SignalR; using Microsoft.CodeAnalysis; using Microsoft.EntityFrameworkCore; using System.Globalization; using Document = Marco.Pms.Model.DocumentManager.Document; namespace MarcoBMS.Services.Controllers { [Authorize] [ApiController] [Route("api/[controller]")] public class AttendanceController : ControllerBase { private readonly ApplicationDbContext _context; private readonly IServiceScopeFactory _serviceScopeFactory; private readonly UserHelper _userHelper; private readonly PermissionServices _permission; private readonly ILoggingService _logger; private readonly Guid tenantId; private readonly IMapper _mapper; public AttendanceController( ApplicationDbContext context, UserHelper userHelper, IServiceScopeFactory serviceScopeFactory, ILoggingService logger, PermissionServices permission, IMapper mapper) { _context = context; _serviceScopeFactory = serviceScopeFactory; _userHelper = userHelper; _logger = logger; _permission = permission; _mapper = mapper; tenantId = userHelper.GetTenantId(); } [HttpGet("log/attendance/{attendanceid}")] public async Task GetAttendanceLogById(Guid attendanceid) { using var scope = _serviceScopeFactory.CreateScope(); var _s3Service = scope.ServiceProvider.GetRequiredService(); List lstAttendance = await _context.AttendanceLogs .Include(a => a.Document) .Include(a => a.Employee) .Include(a => a.UpdatedByEmployee) .Where(c => c.AttendanceId == attendanceid && c.TenantId == tenantId) .ToListAsync(); List attendanceLogVMs = new List(); foreach (var attendanceLog in lstAttendance) { string objectKey = attendanceLog.Document != null ? attendanceLog.Document.S3Key : string.Empty; string preSignedUrl = string.IsNullOrEmpty(objectKey) ? string.Empty : _s3Service.GeneratePreSignedUrl(objectKey); attendanceLogVMs.Add(attendanceLog.ToAttendanceLogVMFromAttendanceLog(preSignedUrl, preSignedUrl)); } _logger.LogInfo("{count} Attendance records fetched successfully", lstAttendance.Count); return Ok(ApiResponse.SuccessResponse(attendanceLogVMs, System.String.Format("{0} Attendance records fetched successfully", lstAttendance.Count), 200)); } [HttpGet("log/employee/{employeeId}")] public async Task GetAttendanceLogByEmployeeId(Guid employeeId, [FromQuery] string? dateFrom = null, [FromQuery] string? dateTo = null) { DateTime fromDate = new DateTime(); DateTime toDate = new DateTime(); if (dateFrom != null && DateTime.TryParse(dateFrom, out fromDate) == false) { _logger.LogWarning("User sent Invalid from Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (dateTo != null && DateTime.TryParse(dateTo, out toDate) == false) { _logger.LogWarning("User sent Invalid to Date while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (employeeId == Guid.Empty) { _logger.LogWarning("The employee Id sent by user is empty"); return BadRequest(ApiResponse.ErrorResponse("Employee ID is required and must not be Empty.", "Employee ID is required and must not be empty.", 400)); } List attendances = await _context.Attendes .Where(c => c.EmployeeId == employeeId && c.TenantId == tenantId && c.AttendanceDate.Date >= fromDate && c.AttendanceDate.Date <= toDate).ToListAsync(); Employee? employee = await _context.Employees.Include(e => e.JobRole).FirstOrDefaultAsync(e => e.Id == employeeId && e.TenantId == tenantId && e.IsActive); List results = new List(); if (employee != null) { foreach (var attendance in attendances) { EmployeeAttendanceVM result = new EmployeeAttendanceVM { Id = attendance.Id, EmployeeId = employee.Id, FirstName = employee.FirstName, LastName = employee.LastName, CheckInTime = attendance.InTime, CheckOutTime = attendance.OutTime, JobRoleName = employee.JobRole != null ? employee.JobRole.Name : "", Activity = attendance.Activity, EmployeeAvatar = null }; results.Add(result); } } _logger.LogInfo("{count} Attendance records fetched successfully", results.Count); return Ok(ApiResponse.SuccessResponse(results, System.String.Format("{0} Attendance records fetched successfully", results.Count), 200)); } /// /// /// /// ProjectID /// YYYY-MM-dd /// [HttpGet("project/log")] public async Task EmployeeAttendanceByDateRange([FromQuery] Guid? projectId, [FromQuery] Guid? organizationId, [FromQuery] string? dateFrom = null, [FromQuery] string? dateTo = null) { var LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, LoggedInEmployee.Id); var hasSelfAttendancePermission = await _permission.HasPermission(PermissionsMaster.SelfAttendance, LoggedInEmployee.Id); DateTime fromDate = new DateTime(); DateTime toDate = new DateTime(); if (dateFrom != null && DateTime.TryParse(dateFrom, out fromDate) == false) { _logger.LogWarning("User sent Invalid fromDate while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } if (dateTo != null && DateTime.TryParse(dateTo, out toDate) == false) { _logger.LogWarning("User sent Invalid toDate while featching attendance logs"); return BadRequest(ApiResponse.ErrorResponse("Invalid Date", "Invalid Date", 400)); } var result = new List(); //Attendance? attendance = null; if (dateFrom == null) fromDate = DateTime.UtcNow.Date; if (dateTo == null && dateFrom != null) toDate = fromDate.AddDays(-1); var lstAttendanceQuery = _context.Attendes .Include(a => a.Employee) .ThenInclude(e => e!.JobRole) .Include(a => a.Employee) .ThenInclude(e => e!.JobRole) .Include(a => a.Employee) .ThenInclude(e => e!.Organization) .Include(a => a.Employee) .ThenInclude(e => e!.JobRole) .Where(a => a.AttendanceDate.Date >= fromDate.Date && a.AttendanceDate.Date <= toDate.Date && a.TenantId == tenantId && a.Employee != null && a.Employee.Organization != null && a.Employee.JobRole != null); if (organizationId.HasValue) { lstAttendanceQuery = lstAttendanceQuery.Where(a => a.Employee != null && a.Employee.OrganizationId == organizationId); } if (projectId.HasValue) { lstAttendanceQuery = lstAttendanceQuery.Where(a => a.ProjectID == projectId); } if (hasTeamAttendancePermission) { List lstAttendance = await lstAttendanceQuery.ToListAsync(); var projectIds = lstAttendance.Select(a => a.ProjectID).ToList(); var projects = await _context.Projects.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).ToListAsync(); foreach (Attendance? attendance in lstAttendance) { var result1 = new EmployeeAttendanceVM() { Id = attendance.Id, CheckInTime = attendance.InTime, CheckOutTime = attendance.OutTime, Activity = attendance.Activity, EmployeeId = attendance.EmployeeId, FirstName = attendance.Employee?.FirstName, LastName = attendance.Employee?.LastName, JobRoleName = attendance.Employee?.JobRole?.Name, ProjectName = projects.Where(p => p.Id == attendance.ProjectID).Select(p => p.Name).FirstOrDefault(), OrganizationName = attendance.Employee?.Organization?.Name, RequestedAt = attendance.RequestedAt, RequestedBy = _mapper.Map(attendance.RequestedBy), ApprovedAt = attendance.ApprovedAt, Approver = _mapper.Map(attendance.Approver) }; result.Add(result1); } } else if (hasSelfAttendancePermission) { var lstAttendances = await lstAttendanceQuery.Where(a => a.EmployeeId == LoggedInEmployee.Id).ToListAsync(); var projectIds = lstAttendances.Select(a => a.ProjectID).ToList(); var projects = await _context.Projects.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).ToListAsync(); foreach (var attendance in lstAttendances) { EmployeeAttendanceVM result1 = new EmployeeAttendanceVM { Id = attendance.Id, EmployeeAvatar = null, EmployeeId = attendance.EmployeeId, FirstName = attendance.Employee?.FirstName, LastName = attendance.Employee?.LastName, JobRoleName = attendance.Employee?.JobRole?.Name, ProjectName = projects.Where(p => p.Id == attendance.ProjectID).Select(p => p.Name).FirstOrDefault(), OrganizationName = attendance.Employee?.Organization?.Name, CheckInTime = attendance.InTime, CheckOutTime = attendance.OutTime, Activity = attendance.Activity, RequestedAt = attendance.RequestedAt, RequestedBy = _mapper.Map(attendance.RequestedBy), ApprovedAt = attendance.ApprovedAt, Approver = _mapper.Map(attendance.Approver) }; result.Add(result1); } } _logger.LogInfo("{count} Attendance records fetched successfully", result.Count); return Ok(ApiResponse.SuccessResponse(result, System.String.Format("{0} Attendance records fetched successfully", result.Count), 200)); } [HttpGet("project/team")] /// /// Retrieves employee attendance records for a specified project and date. /// The result is filtered based on the logged-in employee's permissions (Team or Self). /// /// The ID of the project. /// Optional. Filters attendance for employees of a specific organization. /// Optional. Includes inactive employees in the team list if true. /// Optional. The date for which to fetch attendance, in "yyyy-MM-dd" format. Defaults to the current UTC date. /// An IActionResult containing a list of employee attendance records or an error response. public async Task EmployeeAttendanceByProjectAsync([FromQuery] Guid? projectId, [FromQuery] Guid? organizationId, [FromQuery] bool includeInactive, [FromQuery] string? date = null) { var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); // --- 1. Initial Validation and Permission Checks --- _logger.LogInfo("Fetching attendance for ProjectId: {ProjectId}, TenantId: {TenantId}", projectId ?? Guid.Empty, tenantId); // Validate date format if (!DateTime.TryParse(date, out var forDate)) { forDate = DateTime.UtcNow.Date; // Default to today's date } // --- 2. Delegate to Specific Logic Based on Permissions --- try { var hasTeamAttendancePermission = await _permission.HasPermission(PermissionsMaster.TeamAttendance, loggedInEmployee.Id); List result; if (hasTeamAttendancePermission) { if (!organizationId.HasValue) { organizationId = loggedInEmployee.OrganizationId; } _logger.LogInfo("EmployeeId: {EmployeeId} has Team Attendance permission. Fetching team attendance.", loggedInEmployee.Id); result = await GetTeamAttendanceAsync(tenantId, projectId, organizationId.Value, forDate, includeInactive); } else if (await _permission.HasPermission(PermissionsMaster.SelfAttendance, loggedInEmployee.Id)) { _logger.LogInfo("EmployeeId: {EmployeeId} has Self Attendance permission. Fetching self attendance.", loggedInEmployee.Id); result = await GetSelfAttendanceAsync(tenantId, projectId, loggedInEmployee.Id, forDate); } else { _logger.LogWarning("Access denied for EmployeeId: {EmployeeId}. No valid attendance permission found.", loggedInEmployee.Id); return StatusCode(403, ApiResponse.ErrorResponse("You do not have permission to view attendance.", new { }, 403)); } _logger.LogInfo("Successfully fetched {Count} attendance records for ProjectId: {ProjectId}", result.Count, projectId ?? Guid.Empty); return Ok(ApiResponse.SuccessResponse(result, $"{result.Count} attendance records fetched successfully.")); } catch (Exception ex) { _logger.LogError(ex, "An error occurred while fetching attendance for ProjectId: {ProjectId}", projectId ?? Guid.Empty); return StatusCode(500, ApiResponse.ErrorResponse("An internal server error occurred.")); } } [HttpGet("regularize")] public async Task GetRequestRegularizeAttendance([FromQuery] Guid? projectId, [FromQuery] Guid? organizationId, [FromQuery] bool IncludeInActive) { Employee LoggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var result = new List(); var lstAttendanceQuery = _context.Attendes .Include(a => a.RequestedBy) .ThenInclude(e => e!.JobRole) .Include(a => a.Employee) .ThenInclude(e => e!.Organization) .Include(a => a.Employee) .ThenInclude(e => e!.JobRole) .Where(c => c.Activity == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE && c.Employee != null && c.Employee.JobRole != null && c.TenantId == tenantId); if (organizationId.HasValue) { lstAttendanceQuery = lstAttendanceQuery.Where(a => a.Employee != null && a.Employee.OrganizationId == organizationId); } if (projectId.HasValue) { lstAttendanceQuery = lstAttendanceQuery.Where(a => a.ProjectID == projectId); } List lstAttendance = await lstAttendanceQuery.ToListAsync(); var projectIds = lstAttendance.Select(a => a.ProjectID).ToList(); var projects = await _context.Projects.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).ToListAsync(); foreach (Attendance attende in lstAttendance) { var result1 = new EmployeeAttendanceVM() { Id = attende.Id, CheckInTime = attende.InTime, CheckOutTime = attende.OutTime, Activity = attende.Activity, EmployeeAvatar = null, EmployeeId = attende.EmployeeId, FirstName = attende.Employee?.FirstName, ProjectName = projects.Where(p => p.Id == attende.ProjectID).Select(p => p.Name).FirstOrDefault(), LastName = attende.Employee?.LastName, JobRoleName = attende.Employee?.JobRole?.Name, OrganizationName = attende.Employee?.Organization?.Name, RequestedAt = attende.RequestedAt, RequestedBy = _mapper.Map(attende.RequestedBy) }; result.Add(result1); } result.Sort(delegate (EmployeeAttendanceVM x, EmployeeAttendanceVM y) { return string.Compare(x.FirstName, y.FirstName, StringComparison.Ordinal); }); _logger.LogInfo("{count} Attendance records fetched successfully", result.Count); return Ok(ApiResponse.SuccessResponse(result, System.String.Format("{0} Attendance records fetched successfully", result.Count), 200)); } [HttpPost] [Route("record")] public async Task RecordAttendance([FromBody] RecordAttendanceDot recordAttendanceDot) { if (!ModelState.IsValid) { var errors = ModelState.Values .SelectMany(v => v.Errors) .Select(e => e.ErrorMessage) .ToList(); _logger.LogWarning("User sent Invalid Date while marking attendance \n {Error}", string.Join(",", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } using var scope = _serviceScopeFactory.CreateScope(); var _signalR = scope.ServiceProvider.GetRequiredService>(); var _employeeHelper = scope.ServiceProvider.GetRequiredService(); var currentEmployee = await _userHelper.GetCurrentEmployeeAsync(); using var transaction = await _context.Database.BeginTransactionAsync(); try { Attendance? attendance = await _context.Attendes.FirstOrDefaultAsync(a => a.Id == recordAttendanceDot.Id && a.TenantId == tenantId); ; if (recordAttendanceDot.MarkTime == null) { _logger.LogWarning("User sent Invalid Mark Time while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid Mark Time", "Invalid Mark Time", 400)); } DateTime finalDateTime = GetDateFromTimeStamp(recordAttendanceDot.Date, recordAttendanceDot.MarkTime); if (recordAttendanceDot.Comment == null) { _logger.LogWarning("User sent Invalid comment while marking attendance"); return BadRequest(ApiResponse.ErrorResponse("Invalid Comment", "Invalid Comment", 400)); } if (attendance != null) { attendance.Comment = recordAttendanceDot.Comment; if (recordAttendanceDot.Action == ATTENDANCE_MARK_TYPE.CHECK_IN) { attendance.InTime = finalDateTime; attendance.OutTime = null; attendance.Activity = ATTENDANCE_MARK_TYPE.CHECK_OUT; } else if (recordAttendanceDot.Action == ATTENDANCE_MARK_TYPE.CHECK_OUT) { attendance.IsApproved = true; attendance.Activity = ATTENDANCE_MARK_TYPE.REGULARIZE; //string timeString = "10:30 PM"; // Format: "hh:mm tt" attendance.OutTime = finalDateTime; } else if (recordAttendanceDot.Action == ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE) { DateTime date = attendance.AttendanceDate; finalDateTime = GetDateFromTimeStamp(date.Date, recordAttendanceDot.MarkTime); if (attendance.InTime <= finalDateTime) { attendance.OutTime = finalDateTime; attendance.Activity = ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE; attendance.RequestedAt = DateTime.UtcNow; attendance.RequestedById = currentEmployee.Id; } else { _logger.LogWarning("Employee {EmployeeId} sent regularization request but it check-out time is earlier than check-out"); return BadRequest(ApiResponse.ErrorResponse("Check-out time must be later than check-in time", "Check-out time must be later than check-in time", 400)); } // do nothing } else if (recordAttendanceDot.Action == ATTENDANCE_MARK_TYPE.REGULARIZE) { attendance.IsApproved = true; attendance.Activity = ATTENDANCE_MARK_TYPE.REGULARIZE; attendance.ApprovedById = currentEmployee.Id; attendance.ApprovedAt = DateTime.UtcNow; // do nothing } else if (recordAttendanceDot.Action == ATTENDANCE_MARK_TYPE.REGULARIZE_REJECT) { attendance.IsApproved = false; attendance.Activity = ATTENDANCE_MARK_TYPE.REGULARIZE_REJECT; attendance.ApprovedById = currentEmployee.Id; attendance.ApprovedAt = DateTime.UtcNow; // do nothing } attendance.Date = DateTime.UtcNow; // update code _context.Attendes.Update(attendance); } else { attendance = new Attendance(); attendance.TenantId = tenantId; attendance.AttendanceDate = recordAttendanceDot.Date; // attendance.Activity = recordAttendanceDot.Action; attendance.Comment = recordAttendanceDot.Comment; attendance.EmployeeId = recordAttendanceDot.EmployeeID; attendance.ProjectID = recordAttendanceDot.ProjectID; attendance.Date = DateTime.UtcNow; attendance.InTime = finalDateTime; attendance.OutTime = null; attendance.Activity = ATTENDANCE_MARK_TYPE.CHECK_OUT; _context.Attendes.Add(attendance); } await _context.SaveChangesAsync(); // Step 3: Always insert a new log entry var attendanceLog = new AttendanceLog { AttendanceId = attendance.Id, // Use existing or new AttendanceId Activity = attendance.Activity, ActivityTime = finalDateTime, Comment = recordAttendanceDot.Comment, EmployeeID = recordAttendanceDot.EmployeeID, Latitude = recordAttendanceDot.Latitude, Longitude = recordAttendanceDot.Longitude, TenantId = tenantId, UpdatedBy = currentEmployee.Id, UpdatedOn = recordAttendanceDot.Date }; //if (recordAttendanceDot.Image != null && recordAttendanceDot.Image.Count > 0) //{ // attendanceLog.Photo = recordAttendanceDot.Image[0].Base64Data; //} _context.AttendanceLogs.Add(attendanceLog); await _context.SaveChangesAsync(); await transaction.CommitAsync(); // Commit transaction Employee employee = await _employeeHelper.GetEmployeeByID(recordAttendanceDot.EmployeeID); if (employee.JobRole != null) { EmployeeAttendanceVM vm = new EmployeeAttendanceVM() { CheckInTime = attendance.InTime, CheckOutTime = attendance.OutTime, EmployeeAvatar = null, EmployeeId = recordAttendanceDot.EmployeeID, FirstName = employee.FirstName, LastName = employee.LastName, Id = attendance.Id, Activity = attendance.Activity, JobRoleName = employee.JobRole.Name }; var sendActivity = 0; if (recordAttendanceDot.Id == Guid.Empty) { sendActivity = 1; } var notification = new { LoggedInUserId = currentEmployee.Id, Keyword = "Attendance", Activity = sendActivity, ProjectId = attendance.ProjectID, Response = vm }; await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); _logger.LogInfo("Attendance for employee {FirstName} {LastName} has been marked", employee.FirstName ?? string.Empty, employee.LastName ?? string.Empty); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var _firebase = scope.ServiceProvider.GetRequiredService(); var name = $"{vm.FirstName} {vm.LastName}"; await _firebase.SendAttendanceMessageAsync(attendance.ProjectID, name, recordAttendanceDot.Action, attendance.EmployeeId, tenantId); }); return Ok(ApiResponse.SuccessResponse(vm, "Attendance marked successfully.", 200)); } _logger.LogInfo("Attendance for employee {FirstName} {LastName} has been marked", employee.FirstName ?? string.Empty, employee.LastName ?? string.Empty); return Ok(ApiResponse.SuccessResponse(new EmployeeAttendanceVM(), "Attendance marked successfully.", 200)); } catch (Exception ex) { await transaction.RollbackAsync(); // Rollback on failure _logger.LogError(ex, "An Error occured while marking attendance"); var response = new { message = ex.Message, detail = ex.StackTrace, statusCode = StatusCodes.Status500InternalServerError }; return BadRequest(ApiResponse.ErrorResponse(ex.Message, response, 400)); } } [HttpPost] [Route("record-image")] public async Task RecordAttendanceWithImage([FromBody] RecordAttendanceDot recordAttendanceDot) { if (!ModelState.IsValid) { var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); _logger.LogWarning("Invalid attendance model received. \n {Error}", string.Join(",", errors)); return BadRequest(ApiResponse.ErrorResponse("Invalid data", errors, 400)); } using var scope = _serviceScopeFactory.CreateScope(); var _s3Service = scope.ServiceProvider.GetRequiredService(); var _employeeHelper = scope.ServiceProvider.GetRequiredService(); var _firebase = scope.ServiceProvider.GetRequiredService(); var _signalR = scope.ServiceProvider.GetRequiredService>(); var loggedInEmployee = await _userHelper.GetCurrentEmployeeAsync(); var batchId = Guid.NewGuid(); using var transaction = await _context.Database.BeginTransactionAsync(); try { // Validate mark time if (recordAttendanceDot.MarkTime == null) { _logger.LogWarning("Null mark time provided."); return BadRequest(ApiResponse.ErrorResponse("Invalid Mark Time", "Mark time is required", 400)); } if (string.IsNullOrWhiteSpace(recordAttendanceDot.Comment)) { _logger.LogWarning("Empty comment provided."); return BadRequest(ApiResponse.ErrorResponse("Invalid Comment", "Comment is required", 400)); } var finalDateTime = GetDateFromTimeStamp(recordAttendanceDot.Date, recordAttendanceDot.MarkTime); var attendance = await _context.Attendes .FirstOrDefaultAsync(a => a.Id == recordAttendanceDot.Id && a.TenantId == tenantId); // Create or update attendance if (attendance == null) { attendance = new Attendance { TenantId = tenantId, AttendanceDate = recordAttendanceDot.Date, Comment = recordAttendanceDot.Comment, EmployeeId = recordAttendanceDot.EmployeeID, ProjectID = recordAttendanceDot.ProjectID, Date = DateTime.UtcNow, InTime = finalDateTime, Activity = ATTENDANCE_MARK_TYPE.CHECK_OUT }; _context.Attendes.Add(attendance); } else { attendance.Comment = recordAttendanceDot.Comment; attendance.Date = DateTime.UtcNow; switch (recordAttendanceDot.Action) { case ATTENDANCE_MARK_TYPE.CHECK_IN: attendance.InTime = finalDateTime; attendance.OutTime = null; attendance.Activity = ATTENDANCE_MARK_TYPE.CHECK_OUT; break; case ATTENDANCE_MARK_TYPE.CHECK_OUT: attendance.OutTime = finalDateTime; attendance.Activity = ATTENDANCE_MARK_TYPE.REGULARIZE; attendance.IsApproved = true; break; case ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE: DateTime date = attendance.InTime ?? recordAttendanceDot.Date; finalDateTime = GetDateFromTimeStamp(date.Date, recordAttendanceDot.MarkTime); if (attendance.InTime < finalDateTime) { attendance.OutTime = finalDateTime; attendance.Activity = ATTENDANCE_MARK_TYPE.REQUEST_REGULARIZE; attendance.RequestedAt = DateTime.UtcNow; attendance.RequestedById = loggedInEmployee.Id; } else { _logger.LogWarning("Regularization check-out time is before check-in."); return BadRequest(ApiResponse.ErrorResponse("Check-out time must be later than check-in time", "Invalid regularization", 400)); } break; case ATTENDANCE_MARK_TYPE.REGULARIZE: attendance.IsApproved = true; attendance.Activity = ATTENDANCE_MARK_TYPE.REGULARIZE; attendance.ApprovedAt = DateTime.UtcNow; attendance.ApprovedById = loggedInEmployee.Id; break; case ATTENDANCE_MARK_TYPE.REGULARIZE_REJECT: attendance.IsApproved = false; attendance.Activity = ATTENDANCE_MARK_TYPE.REGULARIZE_REJECT; attendance.ApprovedAt = DateTime.UtcNow; attendance.ApprovedById = loggedInEmployee.Id; break; } _context.Attendes.Update(attendance); } // Upload image if present Document? document = null; string? preSignedUrl = null; if (recordAttendanceDot.Image != null && recordAttendanceDot.Image.ContentType != null) { string base64 = recordAttendanceDot.Image.Base64Data?.Split(',').LastOrDefault() ?? ""; if (string.IsNullOrWhiteSpace(base64)) return BadRequest(ApiResponse.ErrorResponse("Base64 data is missing", "Image data missing", 400)); var fileType = _s3Service.GetContentTypeFromBase64(base64); var fileName = _s3Service.GenerateFileName(fileType, tenantId, "attendance"); var objectKey = $"tenant-{tenantId}/Employee/{recordAttendanceDot.EmployeeID}/Attendance/{fileName}"; await _s3Service.UploadFileAsync(base64, fileType, objectKey); preSignedUrl = _s3Service.GeneratePreSignedUrl(objectKey); document = new Document { BatchId = batchId, UploadedById = loggedInEmployee.Id, FileName = recordAttendanceDot.Image.FileName ?? "", ContentType = recordAttendanceDot.Image.ContentType, S3Key = objectKey, //Base64Data = recordAttendanceDot.Image.Base64Data, FileSize = recordAttendanceDot.Image.FileSize, UploadedAt = recordAttendanceDot.Date, TenantId = tenantId }; _context.Documents.Add(document); } // Log attendance var attendanceLog = new AttendanceLog { AttendanceId = attendance.Id, Activity = attendance.Activity, ActivityTime = finalDateTime, Comment = recordAttendanceDot.Comment, EmployeeID = recordAttendanceDot.EmployeeID, Latitude = recordAttendanceDot.Latitude, Longitude = recordAttendanceDot.Longitude, DocumentId = document?.Id, TenantId = tenantId, UpdatedBy = loggedInEmployee.Id, UpdatedOn = recordAttendanceDot.Date }; _context.AttendanceLogs.Add(attendanceLog); await _context.SaveChangesAsync(); await transaction.CommitAsync(); // Construct view model var employee = await _employeeHelper.GetEmployeeByID(recordAttendanceDot.EmployeeID); var vm = new EmployeeAttendanceVM { Id = attendance.Id, EmployeeId = employee.Id, FirstName = employee.FirstName, LastName = employee.LastName, CheckInTime = attendance.InTime, CheckOutTime = attendance.OutTime, Activity = attendance.Activity, JobRoleName = employee.JobRole?.Name, DocumentId = document?.Id ?? Guid.Empty, ThumbPreSignedUrl = preSignedUrl ?? "", PreSignedUrl = preSignedUrl ?? "" }; var notification = new { LoggedInUserId = loggedInEmployee.Id, Keyword = "Attendance", Activity = recordAttendanceDot.Id == Guid.Empty ? 1 : 0, ProjectId = attendance.ProjectID, Response = vm }; await _signalR.Clients.All.SendAsync("NotificationEventHandler", notification); _ = Task.Run(async () => { // --- Push Notification Section --- // This section attempts to send a test push notification to the user's device. // It's designed to fail gracefully and handle invalid Firebase Cloud Messaging (FCM) tokens. var name = $"{vm.FirstName} {vm.LastName}"; await _firebase.SendAttendanceMessageAsync(attendance.ProjectID, name, recordAttendanceDot.Action, attendance.EmployeeId, tenantId); }); _logger.LogInfo("Attendance recorded for employee: {FullName}", $"{employee.FirstName} {employee.LastName}"); return Ok(ApiResponse.SuccessResponse(vm, "Attendance marked successfully.", 200)); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Error while recording attendance"); return BadRequest(ApiResponse.ErrorResponse("Something went wrong", ex.Message, 500)); } } private static DateTime GetDateFromTimeStamp(DateTime date, string timeString) { //DateTime date = recordAttendanceDot.Date; // Parse time string to TimeSpan DateTime parsedTime = DateTime.ParseExact(timeString, "hh:mm tt", CultureInfo.InvariantCulture); // Combine date with time DateTime finalDateTime = new DateTime(date.Year, date.Month, date.Day, parsedTime.Hour, parsedTime.Minute, 0); return finalDateTime; } /// /// Fetches attendance for an entire project team using a single, optimized database query. /// private async Task> GetTeamAttendanceAsync(Guid tenantId, Guid? projectId, Guid organizationId, DateTime forDate, bool includeInactive) { // This single query joins ProjectAllocations with Employees and performs a LEFT JOIN with Attendances. // This is far more efficient than fetching collections and joining them in memory. var query = _context.Employees .Include(e => e!.Organization) .Include(e => e!.JobRole) .Where(e => e.OrganizationId == organizationId && e.Organization != null && e.JobRole != null && e.IsActive); var lstAttendanceQuery = _context.Attendes.Where(c => c.AttendanceDate.Date == forDate && c.TenantId == tenantId); if (projectId.HasValue) { lstAttendanceQuery = lstAttendanceQuery.Where(a => a.ProjectID == projectId); } List lstAttendance = await lstAttendanceQuery.ToListAsync(); var employees = await query .AsNoTracking() .ToListAsync(); var projectIds = lstAttendance.Select(a => a.ProjectID).ToList(); var projects = await _context.Projects.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).ToListAsync(); var response = employees .Select(employee => { var result1 = new EmployeeAttendanceVM() { EmployeeAvatar = null, EmployeeId = employee.Id, FirstName = employee.FirstName, LastName = employee.LastName, OrganizationName = employee.Organization!.Name, JobRoleName = employee.JobRole!.Name, }; var attendance = lstAttendance.Find(x => x.EmployeeId == employee.Id) ?? new Attendance(); if (attendance != null) { result1.Id = attendance.Id; result1.CheckInTime = attendance.InTime; result1.CheckOutTime = attendance.OutTime; result1.Activity = attendance.Activity; result1.ProjectName = projects.Where(p => p.Id == attendance.ProjectID).Select(p => p.Name).FirstOrDefault(); } return result1; }) .OrderBy(vm => vm.FirstName) // Let the database handle sorting. .ThenBy(vm => vm.LastName).ToList(); return response; } /// /// Fetches a single attendance record for the logged-in employee. /// private async Task> GetSelfAttendanceAsync(Guid tenantId, Guid? projectId, Guid employeeId, DateTime forDate) { List result = new List(); // This query fetches the employee's project allocation and their attendance in a single trip. var lstAttendanceQuery = _context.Attendes .Where(c => c.EmployeeId == employeeId && c.AttendanceDate.Date == forDate && c.TenantId == tenantId); if (projectId.HasValue) { lstAttendanceQuery = lstAttendanceQuery.Where(a => a.ProjectID == projectId); } List lstAttendances = await lstAttendanceQuery.ToListAsync() ?? new List(); var projectIds = lstAttendances.Select(a => a.ProjectID).ToList(); var projects = await _context.Projects.Where(p => projectIds.Contains(p.Id) && p.TenantId == tenantId).ToListAsync(); var employee = await _context.Employees .Include(e => e.Organization) .Include(e => e.JobRole) .FirstOrDefaultAsync(e => e.Id == employeeId && e.IsActive); if (employee != null && employee.JobRole != null && employee.Organization != null) { foreach (var lstAttendance in lstAttendances) { EmployeeAttendanceVM result1 = new EmployeeAttendanceVM { Id = lstAttendance.Id, EmployeeAvatar = null, EmployeeId = employee.Id, FirstName = employee.FirstName, OrganizationName = employee.Organization.Name, ProjectName = projects.Where(p => p.Id == lstAttendance.ProjectID).Select(p => p.Name).FirstOrDefault(), LastName = employee.LastName, JobRoleName = employee.JobRole.Name, CheckInTime = lstAttendance.InTime, CheckOutTime = lstAttendance.OutTime, Activity = lstAttendance.Activity }; result.Add(result1); } } return result; } } }