using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PSTW_CentralSystem.Areas.IT.Models; using PSTW_CentralSystem.DBContext; using PSTW_CentralSystem.Models; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using PSTW_CentralSystem.Areas.IT.Printing; using QuestPDF.Fluent; using Org.BouncyCastle.Ocsp; namespace PSTW_CentralSystem.Controllers.API { [ApiController] [Route("[controller]")] [Authorize] // keep your policy if needed public class ItRequestAPI : ControllerBase { private static string Nz(string? s, string fallback = "") => string.IsNullOrWhiteSpace(s) ? fallback : s; private readonly CentralSystemContext _db; private readonly UserManager _userManager; public ItRequestAPI(CentralSystemContext db, UserManager userManager) { _db = db; _userManager = userManager; } // Helper for Edit window private const int EDIT_WINDOW_HOURS_DEFAULT = 24; // <- change here anytime private const int EDIT_WINDOW_HOURS_MIN = 1; private const int EDIT_WINDOW_HOURS_MAX = 24 * 14; // clamp for safety (2 weeks) // === Feature flags === // Toggle this to true if later wants Pending (no approvals yet) to be cancelable. private const bool ALLOW_PENDING_CANCEL = false; private static int ResolveEditWindowHours(HttpRequest req, int? fromDto) { // Priority: DTO -> Header -> Query -> Default if (fromDto.HasValue) return Math.Clamp(fromDto.Value, EDIT_WINDOW_HOURS_MIN, EDIT_WINDOW_HOURS_MAX); if (int.TryParse(req.Headers["X-Edit-Window-Hours"], out var h)) return Math.Clamp(h, EDIT_WINDOW_HOURS_MIN, EDIT_WINDOW_HOURS_MAX); if (int.TryParse(req.Query["editWindowHours"], out var q)) return Math.Clamp(q, EDIT_WINDOW_HOURS_MIN, EDIT_WINDOW_HOURS_MAX); return EDIT_WINDOW_HOURS_DEFAULT; } // Helper to get current user id (int) private int GetCurrentUserIdOrThrow() { var userIdStr = _userManager.GetUserId(User); if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) throw new UnauthorizedAccessException("Invalid user"); return currentUserId; } // -------------------------------------------------------------------- // GET: /ItRequestAPI/users?q=&take=100 // Returns light user list for dropdowns // -------------------------------------------------------------------- [HttpGet("users")] public async Task GetUsers([FromQuery] string? q = null, [FromQuery] int take = 200) { take = Math.Clamp(take, 1, 500); var users = _db.Users.AsQueryable(); if (!string.IsNullOrWhiteSpace(q)) { q = q.Trim(); users = users.Where(u => (u.UserName ?? "").Contains(q) || (u.FullName ?? "").Contains(q) || (u.Email ?? "").Contains(q)); } var data = await users .OrderBy(u => u.Id) .Take(take) .Select(u => new { id = u.Id, name = u.FullName ?? u.UserName, userName = u.UserName, email = u.Email }) .ToListAsync(); return Ok(data); } // ===== Section B: Meta ===== [HttpGet("sectionB/meta")] public async Task SectionBMeta([FromQuery] int statusId) { int currentUserId; try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(new { message = ex.Message }); } var status = await _db.ItRequestStatus .Include(s => s.Request) .Include(s => s.Flow) .FirstOrDefaultAsync(s => s.StatusId == statusId); if (status == null) return NotFound(new { message = "Status not found." }); if (status.Request == null) return StatusCode(409, new { message = "This status row is orphaned (missing its request). Please re-create the request or fix the foreign key." }); var req = status.Request!; var sectionB = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); bool isRequestor = req.UserId == currentUserId; bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); bool sectionBSent = sectionB?.SectionBSent == true; bool locked = sectionBSent || (sectionB?.RequestorAccepted == true && sectionB?.ItAccepted == true); return Ok(new { overallStatus = status.OverallStatus ?? "Pending", requestorName = req.StaffName, isRequestor, isItMember, sectionB = sectionB == null ? null : new { saved = true, assetNo = sectionB.AssetNo ?? "", machineId = sectionB.MachineId ?? "", ipAddress = sectionB.IpAddress ?? "", wiredMac = sectionB.WiredMac ?? "", wifiMac = sectionB.WifiMac ?? "", dialupAcc = sectionB.DialupAcc ?? "", remarks = sectionB.Remarks ?? "", lastEditedBy = sectionB.LastEditedByName ?? "", lastEditedAt = sectionB.LastEditedAt }, sectionBSent, sectionBSentAt = sectionB?.SectionBSentAt, locked, requestorAccepted = sectionB?.RequestorAccepted ?? false, requestorAcceptedAt = sectionB?.RequestorAcceptedAt, itAccepted = sectionB?.ItAccepted ?? false, itAcceptedAt = sectionB?.ItAcceptedAt, itAcceptedBy = sectionB?.ItAcceptedByName }); } [HttpGet("sectionB/pdf")] public async Task SectionBPdf([FromQuery] int statusId) { var status = await _db.ItRequestStatus .Include(s => s.Request).ThenInclude(r => r!.Hardware) .Include(s => s.Request).ThenInclude(r => r!.Emails) .Include(s => s.Request).ThenInclude(r => r!.OsRequirements) .Include(s => s.Request).ThenInclude(r => r!.Software) .Include(s => s.Request).ThenInclude(r => r!.SharedPermissions) .Include(s => s.Flow) .FirstOrDefaultAsync(s => s.StatusId == statusId); if (status == null) return NotFound(); if (status.Request == null) return StatusCode(409, new { message = "Missing Request for this status." }); var b = await _db.ItRequestAssetInfo .SingleOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); string? hardwareJustification = null; if (status.Request?.Hardware != null) { var firstDistinct = status.Request.Hardware .Select(h => h.Justification?.Trim()) .Where(j => !string.IsNullOrWhiteSpace(j)) .Distinct(StringComparer.InvariantCultureIgnoreCase) .FirstOrDefault(); hardwareJustification = firstDistinct; } if (status is null) return NotFound(); if (status.Request is null) return StatusCode(409, new { message = "Missing Request for this status." }); var req = status.Request; // non-null after the guard // Build the report model var m = new ItRequestReportModel { ItRequestId = status.ItRequestId, StatusId = status.StatusId, OverallStatus = status.OverallStatus ?? "Pending", SubmitDate = req.SubmitDate, // if nullable: req.SubmitDate ?? DateTime.MinValue StaffName = req.StaffName ?? "", CompanyName = req.CompanyName ?? "", DepartmentName = req.DepartmentName ?? "", Designation = req.Designation ?? "", Location = req.Location ?? "", EmploymentStatus = req.EmploymentStatus ?? "", ContractEndDate = req.ContractEndDate, RequiredDate = req.RequiredDate, PhoneExt = req.PhoneExt ?? "", // …keep the rest, but switch to 'req' for child lists too: Hardware = req.Hardware?.Select(h => string.IsNullOrWhiteSpace(h.OtherDescription) ? (h.Category ?? "") : $"{h.Category} – {h.OtherDescription}") .Where(s => !string.IsNullOrWhiteSpace(s)).ToList() ?? new(), Justification = hardwareJustification, Emails = req.Emails? .Select(e => e.ProposedAddress ?? "") .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList() ?? new(), OsRequirements = req.OsRequirements? .Select(o => o.RequirementText ?? "") .Where(s => !string.IsNullOrWhiteSpace(s)) .ToList() ?? new(), Software = req.Software? .Select(sw => (sw.Bucket ?? "", sw.Name ?? "", sw.OtherName, sw.Notes)) .ToList() ?? new(), SharedPerms = req.SharedPermissions? .Select(sp => (sp.ShareName ?? "", sp.CanRead, sp.CanWrite, sp.CanDelete, sp.CanRemove)) .ToList() ?? new(), AssetNo = b?.AssetNo ?? "", MachineId = b?.MachineId ?? "", IpAddress = b?.IpAddress ?? "", WiredMac = b?.WiredMac ?? "", WifiMac = b?.WifiMac ?? "", DialupAcc = b?.DialupAcc ?? "", Remarks = b?.Remarks ?? "", RequestorName = req.StaffName ?? "", RequestorAcceptedAt = b?.RequestorAcceptedAt, ItCompletedBy = b?.ItAcceptedByName ?? "", ItAcceptedAt = b?.ItAcceptedAt }; if (status.Request?.Hardware != null) { foreach (var h in status.Request.Hardware) { var cat = (h.Category ?? "").Trim(); var purpose = (h.Purpose ?? "").Trim().ToLowerInvariant(); // Purposes (left column) if (purpose == "newrecruitment" || purpose == "new recruitment" || purpose == "new") m.HwPurposeNewRecruitment = true; else if (purpose == "replacement") m.HwPurposeReplacement = true; else if (purpose == "additional") m.HwPurposeAdditional = true; // Categories (right column) switch (cat) { case "DesktopAllIn": m.HwDesktopAllIn = true; break; case "NotebookAllIn": m.HwNotebookAllIn = true; break; case "DesktopOnly": m.HwDesktopOnly = true; break; case "NotebookOnly": m.HwNotebookOnly = true; break; case "NotebookBattery": m.HwNotebookBattery = true; break; case "PowerAdapter": m.HwPowerAdapter = true; break; case "Mouse": m.HwMouse = true; break; case "ExternalHDD": m.HwExternalHdd = true; break; case "Other": if (!string.IsNullOrWhiteSpace(h.OtherDescription)) m.HwOtherText = h.OtherDescription!.Trim(); break; } } } // ---- Approver names + stage dates ------------------------------------ // Build a name map for all approver IDs (null-safe) var approverIds = new int?[] { status.Flow?.HodUserId, status.Flow?.GroupItHodUserId, status.Flow?.FinHodUserId, status.Flow?.MgmtUserId }; var idSet = approverIds .Where(i => i.HasValue).Select(i => i!.Value) .Distinct().ToList(); var nameMap = await _db.Users .Where(u => idSet.Contains(u.Id)) .ToDictionaryAsync(u => u.Id, u => u.FullName ?? u.UserName ?? $"User {u.Id}"); string Name(int? id) => id.HasValue ? nameMap.GetValueOrDefault(id.Value, "") : ""; // Push names into the report model for PDF m.HodApprovedBy = Name(status.Flow?.HodUserId); m.GitHodApprovedBy = Name(status.Flow?.GroupItHodUserId); // DB field: GroupItHodUserId m.FinHodApprovedBy = Name(status.Flow?.FinHodUserId); m.MgmtApprovedBy = Name(status.Flow?.MgmtUserId); // Also expose the per-stage submit dates (your PDF uses them) m.HodSubmitDate = status.HodSubmitDate; m.GitHodSubmitDate = status.GitHodSubmitDate; m.FinHodSubmitDate = status.FinHodSubmitDate; m.MgmtSubmitDate = status.MgmtSubmitDate; // ---- Gate: only after both acceptances ------------------------------- if (!(b?.RequestorAccepted == true && b?.ItAccepted == true)) return BadRequest(new { message = "PDF available after both acceptances." }); var pdf = new ItRequestPdfService().Generate(m); var fileName = $"IT_Request_{m.ItRequestId}_SectionB.pdf"; return File(pdf, "application/pdf", fileName); } // ===== Section B: Save (IT team only, overall must be Approved) ===== public class SectionBSaveDto { public int StatusId { get; set; } public string AssetNo { get; set; } = string.Empty; public string MachineId { get; set; } = string.Empty; public string IpAddress { get; set; } = string.Empty; public string WiredMac { get; set; } = string.Empty; public string WifiMac { get; set; } = string.Empty; public string DialupAcc { get; set; } = string.Empty; public string Remarks { get; set; } = string.Empty; } // REPLACE the old method with this one (same route name kept) [HttpGet("sectionB/approvedList")] public async Task SectionBList([FromQuery] int month, [FromQuery] int year) { int currentUserId; try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(new { message = ex.Message }); } bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); if (!isItMember) return Ok(new { isItMember = false, data = Array.Empty() }); // Pull ALL requests for the month by SubmitDate (so "not eligible yet" also appear) var baseQuery = _db.ItRequestStatus .Include(s => s.Request) .Where(s => s.Request != null && s.Request.SubmitDate.Month == month && s.Request.SubmitDate.Year == year) .Select(s => new { S = s, ApprovedAt = s.MgmtSubmitDate ?? s.FinHodSubmitDate ?? s.GitHodSubmitDate ?? s.HodSubmitDate }); var rows = await baseQuery .Select(x => new { x.S.StatusId, x.S.ItRequestId, StaffName = x.S.Request!.StaffName, DepartmentName = x.S.Request.DepartmentName, SubmitDate = x.S.Request.SubmitDate, OverallStatus = x.S.OverallStatus ?? "Draft", ApprovedAt = x.ApprovedAt, B = _db.ItRequestAssetInfo .Where(b => b.ItRequestId == x.S.ItRequestId) .Select(b => new { saved = true, b.SectionBSent, b.RequestorAccepted, b.ItAccepted, b.RequestorAcceptedAt, b.ItAcceptedAt, b.ItAcceptedByName, b.LastEditedAt, b.LastEditedByName }) .FirstOrDefault() }) .ToListAsync(); // Stage derivation string Stage(string overall, dynamic b) { bool isApproved = string.Equals(overall, "Approved", StringComparison.OrdinalIgnoreCase); if (!isApproved) return "NOT_ELIGIBLE"; // first part not approved yet if (b == null) return "PENDING"; // no Section B row yet bool sent = b.SectionBSent == true; bool req = b.RequestorAccepted == true; bool it = b.ItAccepted == true; if (req && it) return "COMPLETE"; // both accepted if (!sent) return "DRAFT"; // saved but not sent return "AWAITING"; // one side accepted } int Rank(string stage) => stage switch { "PENDING" => 0, "DRAFT" => 1, "AWAITING" => 2, "COMPLETE" => 3, "NOT_ELIGIBLE" => 4, _ => 9 }; var data = rows .Select(x => { var stage = Stage(x.OverallStatus, x.B); return new { statusId = x.StatusId, itRequestId = x.ItRequestId, staffName = x.StaffName, departmentName = x.DepartmentName, submitDate = x.SubmitDate, approvedAt = x.ApprovedAt, overallStatus = x.OverallStatus, stage, // <-- used by UI badges/sorting stageRank = Rank(stage), // <-- used by UI sorting sb = new { saved = x.B != null, requestorAccepted = x.B?.RequestorAccepted ?? false, requestorAcceptedAt = x.B?.RequestorAcceptedAt, itAccepted = x.B?.ItAccepted ?? false, itAcceptedAt = x.B?.ItAcceptedAt, itAcceptedBy = x.B?.ItAcceptedByName, lastEditedAt = x.B?.LastEditedAt, lastEditedBy = x.B?.LastEditedByName } }; }) .Where(x => x.stage != "NOT_ELIGIBLE") .OrderBy(x => x.stageRank) .ThenByDescending(x => x.approvedAt ?? x.submitDate) .ToList(); return Ok(new { isItMember = true, data }); } [HttpPost("sectionB/save")] public async Task SectionBSave([FromBody] SectionBSaveDto dto) { int currentUserId; try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } var status = await _db.ItRequestStatus .Include(s => s.Request) .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); if (status == null) return NotFound("Status not found"); if ((status.OverallStatus ?? "Pending") != "Approved") return BadRequest(new { message = "Section B is available only after OverallStatus is Approved." }); bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); if (!isItMember) return Unauthorized("Only IT Team members can edit Section B."); // basic server-side validation (at least one identifier) List errors = new(); if (string.IsNullOrWhiteSpace(dto.AssetNo) && string.IsNullOrWhiteSpace(dto.MachineId) && string.IsNullOrWhiteSpace(dto.IpAddress)) errors.Add("Provide at least one of: Asset No, Machine ID, or IP Address."); if (errors.Count > 0) return BadRequest(new { message = string.Join(" ", errors) }); var existing = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); if (existing != null && existing.SectionBSent) return BadRequest(new { message = "This Section B was sent. It can’t be edited anymore." }); var editor = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); var editorName = editor?.FullName ?? editor?.UserName ?? $"User {currentUserId}"; var now = DateTime.Now; try { if (existing == null) { existing = new ItRequestAssetInfo { ItRequestId = status.ItRequestId, AssetNo = dto.AssetNo?.Trim(), MachineId = dto.MachineId?.Trim(), IpAddress = dto.IpAddress?.Trim(), WiredMac = dto.WiredMac?.Trim(), WifiMac = dto.WifiMac?.Trim(), DialupAcc = dto.DialupAcc?.Trim(), Remarks = dto.Remarks?.Trim(), LastEditedAt = now, LastEditedByUserId = currentUserId, LastEditedByName = editorName, SectionBSent = false, SectionBSentAt = null, // ensure accept flags are clear on draft save RequestorAccepted = false, RequestorAcceptedAt = null, ItAccepted = false, ItAcceptedAt = null, ItAcceptedByUserId = null, ItAcceptedByName = null }; _db.ItRequestAssetInfo.Add(existing); } else { existing.AssetNo = dto.AssetNo?.Trim(); existing.MachineId = dto.MachineId?.Trim(); existing.IpAddress = dto.IpAddress?.Trim(); existing.WiredMac = dto.WiredMac?.Trim(); existing.WifiMac = dto.WifiMac?.Trim(); existing.DialupAcc = dto.DialupAcc?.Trim(); existing.Remarks = dto.Remarks?.Trim(); existing.LastEditedAt = now; existing.LastEditedByUserId = currentUserId; existing.LastEditedByName = editorName; // Saving draft always keeps it editable (not sent) existing.SectionBSent = false; existing.SectionBSentAt = null; // if someone had previously accepted, a new draft save clears them existing.RequestorAccepted = false; existing.RequestorAcceptedAt = null; existing.ItAccepted = false; existing.ItAcceptedAt = null; existing.ItAcceptedByUserId = null; existing.ItAcceptedByName = null; } await _db.SaveChangesAsync(); return Ok(new { message = "Draft saved." }); } catch (DbUpdateException dbx) { return StatusCode(400, new { message = "Validation/DB error while saving Section B.", detail = dbx.InnerException?.Message ?? dbx.Message }); } catch (Exception ex) { return StatusCode(500, new { message = "Server error while saving Section B.", detail = ex.Message }); } } public class SectionBSendDto { public int StatusId { get; set; } public string? AssetNo { get; set; } public string? MachineId { get; set; } public string? IpAddress { get; set; } public string? WiredMac { get; set; } public string? WifiMac { get; set; } public string? DialupAcc { get; set; } public string? Remarks { get; set; } } [HttpPost("sectionB/send")] public async Task SectionBSend([FromBody] SectionBSendDto dto) { int currentUserId; try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } var status = await _db.ItRequestStatus .Include(s => s.Request) .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); if (status == null) return NotFound(new { message = "Status not found" }); if ((status.OverallStatus ?? "Pending") != "Approved") return BadRequest(new { message = "Section B can be sent only after OverallStatus is Approved." }); bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); if (!isItMember) return Unauthorized(new { message = "Only IT can send Section B." }); var b = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); if (b?.SectionBSent == true) return BadRequest(new { message = "Section B already sent." }); var editor = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); var editorName = editor?.FullName ?? editor?.UserName ?? $"User {currentUserId}"; var now = DateTime.Now; if (b == null) { b = new ItRequestAssetInfo { ItRequestId = status.ItRequestId, RequestorAccepted = false, RequestorAcceptedAt = null, ItAccepted = false, ItAcceptedAt = null, ItAcceptedByUserId = null, ItAcceptedByName = null }; _db.ItRequestAssetInfo.Add(b); } // Apply incoming fields (allow send without prior draft) if (dto.AssetNo != null) b.AssetNo = dto.AssetNo.Trim(); if (dto.MachineId != null) b.MachineId = dto.MachineId.Trim(); if (dto.IpAddress != null) b.IpAddress = dto.IpAddress.Trim(); if (dto.WiredMac != null) b.WiredMac = dto.WiredMac.Trim(); if (dto.WifiMac != null) b.WifiMac = dto.WifiMac.Trim(); if (dto.DialupAcc != null) b.DialupAcc = dto.DialupAcc.Trim(); if (dto.Remarks != null) b.Remarks = dto.Remarks.Trim(); b.LastEditedAt = now; b.LastEditedByUserId = currentUserId; b.LastEditedByName = editorName; // Require at least one identifier if (string.IsNullOrWhiteSpace(b.AssetNo) && string.IsNullOrWhiteSpace(b.MachineId) && string.IsNullOrWhiteSpace(b.IpAddress)) return BadRequest(new { message = "Provide at least Asset No / Machine ID / IP Address before sending." }); b.SectionBSent = true; b.SectionBSentAt = now; await _db.SaveChangesAsync(); return Ok(new { message = "Section B sent. It is now locked for editing until both acceptances complete." }); } public class SectionBResetDto { public int StatusId { get; set; } } [HttpPost("sectionB/reset")] public async Task SectionBReset([FromBody] SectionBResetDto dto) { int currentUserId; try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); if (!isItMember) return Unauthorized(new { message = "Only IT can reset Section B." }); var status = await _db.ItRequestStatus .Include(s => s.Request) .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); if (status == null) return NotFound(new { message = "Status not found" }); var b = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); if (b == null) return Ok(new { message = "Nothing to reset." }); if (b.SectionBSent) return BadRequest(new { message = "Cannot reset after Section B was sent." }); b.AssetNo = b.MachineId = b.IpAddress = b.WiredMac = b.WifiMac = b.DialupAcc = b.Remarks = null; b.RequestorAccepted = false; b.RequestorAcceptedAt = null; b.ItAccepted = false; b.ItAcceptedAt = null; b.ItAcceptedByUserId = null; b.ItAcceptedByName = null; b.LastEditedAt = DateTime.Now; b.LastEditedByUserId = currentUserId; b.LastEditedByName = (await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId))?.FullName; await _db.SaveChangesAsync(); return Ok(new { message = "Section B reset to empty draft." }); } // ===== Section B: Accept (REQUESTOR or IT) ===== public class SectionBAcceptDto { public int StatusId { get; set; } public string By { get; set; } = string.Empty; } // REQUESTOR | IT [HttpPost("sectionB/accept")] public async Task SectionBAccept([FromBody] SectionBAcceptDto dto) { int currentUserId; try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } var status = await _db.ItRequestStatus .Include(s => s.Request) .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); if (status == null) return NotFound("Status not found"); var sectionB = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); if (sectionB == null) return BadRequest(new { message = "Save Section B before acceptance." }); if (!sectionB.SectionBSent) return BadRequest(new { message = "Acceptances are only available after Section B is sent." }); var now = DateTime.Now; var actor = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); var actorName = actor?.FullName ?? actor?.UserName ?? $"User {currentUserId}"; if (string.Equals(dto.By, "REQUESTOR", StringComparison.OrdinalIgnoreCase)) { if (status.Request.UserId != currentUserId) return Unauthorized("Only the requestor can perform this acceptance."); if (sectionB.RequestorAccepted) return BadRequest(new { message = "Requestor already accepted." }); sectionB.RequestorAccepted = true; sectionB.RequestorAcceptedAt = now; } else if (string.Equals(dto.By, "IT", StringComparison.OrdinalIgnoreCase)) { bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); if (!isItMember) return Unauthorized("Only IT Team members can accept as IT."); if (sectionB.ItAccepted) return BadRequest(new { message = "IT already accepted." }); sectionB.ItAccepted = true; sectionB.ItAcceptedAt = now; sectionB.ItAcceptedByUserId = currentUserId; sectionB.ItAcceptedByName = actorName; } else { return BadRequest(new { message = "Unknown acceptance type." }); } await _db.SaveChangesAsync(); return Ok(new { message = "Accepted." }); } // ===== IT Team maintenance (simple stub) ===== // GET: /ItRequestAPI/itTeam [HttpGet("itTeam")] public async Task GetItTeam() => Ok(await _db.ItTeamMembers.Select(t => t.UserId).ToListAsync()); // POST: /ItRequestAPI/itTeam body: { userIds: [1,2,3] } public class ItTeamDto { public List UserIds { get; set; } = new(); } [HttpPost("itTeam")] public async Task SetItTeam([FromBody] ItTeamDto dto) { // Add your own admin authorization guard here var all = _db.ItTeamMembers; _db.ItTeamMembers.RemoveRange(all); await _db.SaveChangesAsync(); foreach (var uid in dto.UserIds.Distinct()) _db.ItTeamMembers.Add(new ItTeamMember { UserId = uid }); await _db.SaveChangesAsync(); return Ok(new { message = "IT Team updated." }); } // -------------------------------------------------------------------- // GET: /ItRequestAPI/me // Pull snapshot from aspnetusers -> departments -> companies // Used by Create.cshtml to prefill read-only requester details // -------------------------------------------------------------------- [HttpGet("me")] public async Task Me() { var userIdStr = _userManager.GetUserId(User); if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) return Unauthorized("Invalid user"); var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); if (user == null) return NotFound("User not found"); var dept = await _db.Departments.FirstOrDefaultAsync(d => d.DepartmentId == user.departmentId); var comp = dept != null ? await _db.Companies.FirstOrDefaultAsync(c => c.CompanyId == dept.CompanyId) : null; return Ok(new { userId = user.Id, staffName = user.FullName ?? user.UserName, departmentId = dept?.DepartmentId, departmentName = dept?.DepartmentName ?? "", companyId = comp?.CompanyId, companyName = comp?.CompanyName ?? "", designation = "", // adjust if you actually store this somewhere location = "", employmentStatus = "", contractEndDate = (DateTime?)null, phoneExt = user.PhoneNumber ?? "" }); } #region Approval razor // -------------------------------------------------------------------- // GET: /ItRequestAPI/pending?month=9&year=2025 // -------------------------------------------------------------------- [HttpGet("pending")] public async Task GetPending(int month, int year) { var userIdStr = _userManager.GetUserId(User); if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) return Unauthorized("Invalid or missing user ID"); // Which flows is this user part of, and what role in each? var flows = await _db.ItApprovalFlows .Where(f => f.HodUserId == currentUserId || f.GroupItHodUserId == currentUserId || f.FinHodUserId == currentUserId || f.MgmtUserId == currentUserId) .ToListAsync(); if (!flows.Any()) return Ok(new { roles = Array.Empty(), data = Array.Empty() }); var flowRoleMap = flows.ToDictionary( f => f.ItApprovalFlowId, f => f.HodUserId == currentUserId ? "HOD" : f.GroupItHodUserId == currentUserId ? "GIT_HOD" : f.FinHodUserId == currentUserId ? "FIN_HOD" : "MGMT" ); var statuses = await _db.ItRequestStatus .Include(s => s.Request) .Where(s => s.Request != null && s.Request.SubmitDate.Month == month && s.Request.SubmitDate.Year == year && (s.OverallStatus ?? "Draft") != "Draft" // hide drafts && s.OverallStatus != "Cancelled") .ToListAsync(); var results = statuses .Where(s => flowRoleMap.ContainsKey(s.ItApprovalFlowId)) .Select(s => { var role = flowRoleMap[s.ItApprovalFlowId]; // quick flags bool hodApproved = s.HodStatus == "Approved"; bool gitApproved = s.GitHodStatus == "Approved"; bool finApproved = s.FinHodStatus == "Approved"; bool anyRejected = (s.HodStatus == "Rejected" || s.GitHodStatus == "Rejected" || s.FinHodStatus == "Rejected" || s.MgmtStatus == "Rejected"); string currentUserStatus = "N/A"; bool canApprove = false; if (role == "HOD") { currentUserStatus = s.HodStatus ?? "Pending"; canApprove = (currentUserStatus == "Pending"); // first approver always sees its own pending } else if (role == "GIT_HOD") { // only after HOD approved and not previously rejected if (!anyRejected && hodApproved) { currentUserStatus = s.GitHodStatus ?? "Pending"; canApprove = (currentUserStatus == "Pending"); } else { // don’t surface “pending” for future approver currentUserStatus = (s.GitHodStatus ?? "N/A"); } } else if (role == "FIN_HOD") { if (!anyRejected && hodApproved && gitApproved) { currentUserStatus = s.FinHodStatus ?? "Pending"; canApprove = (currentUserStatus == "Pending"); } else { currentUserStatus = (s.FinHodStatus ?? "N/A"); } } else // MGMT { if (!anyRejected && hodApproved && gitApproved && finApproved) { currentUserStatus = s.MgmtStatus ?? "Pending"; canApprove = (currentUserStatus == "Pending"); } else { currentUserStatus = (s.MgmtStatus ?? "N/A"); } } // include row ONLY IF: // - this approver can act now (pending for them), OR // - this approver already decided (for Completed tab) bool includeForUser = canApprove || currentUserStatus == "Approved" || currentUserStatus == "Rejected"; return new { s.StatusId, s.ItRequestId, s.Request.StaffName, s.Request.DepartmentName, SubmitDate = s.Request.SubmitDate, s.HodStatus, s.GitHodStatus, s.FinHodStatus, s.MgmtStatus, OverallStatus = s.OverallStatus, Role = role, CurrentUserStatus = currentUserStatus, CanApprove = canApprove, IsOverallRejected = anyRejected }; }) .Where(r => r != null && (r.CanApprove || r.CurrentUserStatus == "Approved" || r.CurrentUserStatus == "Rejected")) .ToList(); return Ok(new { roles = flowRoleMap.Values.Distinct().ToList(), data = results }); } // -------------------------------------------------------------------- // POST: /ItRequestAPI/approveReject // -------------------------------------------------------------------- public class ApproveRejectDto { public int StatusId { get; set; } public string Decision { get; set; } = string.Empty; // "Approved" or "Rejected" public string? Comment { get; set; } } [HttpPost("approveReject")] public async Task ApproveReject([FromBody] ApproveRejectDto dto) { if (dto == null || string.IsNullOrWhiteSpace(dto.Decision)) return BadRequest(new { message = "Invalid request" }); var userIdStr = _userManager.GetUserId(User); if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) return Unauthorized("Invalid user"); var status = await _db.ItRequestStatus .Include(s => s.Flow) .Include(s => s.Request) .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); if (status == null) return NotFound("Status not found"); var overall = status.OverallStatus ?? "Draft"; // Drafts cannot be approved/rejected ever if (overall == "Draft") return StatusCode(409, new { message = "This request isn’t submitted yet. Approvals are only allowed once it is Pending." }); // CANCELLED: hard stop if (string.Equals(overall, "Cancelled", StringComparison.OrdinalIgnoreCase)) return StatusCode(409, new { message = "This request has been Cancelled and cannot be approved or rejected." }); // If any earlier stage already rejected, block if (status.HodStatus == "Rejected" || status.GitHodStatus == "Rejected" || status.FinHodStatus == "Rejected" || status.MgmtStatus == "Rejected") return BadRequest(new { message = "Request already rejected by a previous approver." }); var now = DateTime.Now; string decision = dto.Decision.Trim(); if (status.Flow.HodUserId == currentUserId && status.HodStatus == "Pending") { status.HodStatus = decision; status.HodSubmitDate = now; } else if (status.Flow.GroupItHodUserId == currentUserId && status.GitHodStatus == "Pending" && status.HodStatus == "Approved") { status.GitHodStatus = decision; status.GitHodSubmitDate = now; } else if (status.Flow.FinHodUserId == currentUserId && status.FinHodStatus == "Pending" && status.HodStatus == "Approved" && status.GitHodStatus == "Approved") { status.FinHodStatus = decision; status.FinHodSubmitDate = now; } else if (status.Flow.MgmtUserId == currentUserId && status.MgmtStatus == "Pending" && status.HodStatus == "Approved" && status.GitHodStatus == "Approved" && status.FinHodStatus == "Approved") { status.MgmtStatus = decision; status.MgmtSubmitDate = now; } else { return BadRequest(new { message = "Not authorized to act at this stage." }); } if (decision == "Rejected") status.OverallStatus = "Rejected"; else if (status.HodStatus == "Approved" && status.GitHodStatus == "Approved" && status.FinHodStatus == "Approved" && status.MgmtStatus == "Approved") status.OverallStatus = "Approved"; await _db.SaveChangesAsync(); return Ok(new { message = "Decision recorded successfully." }); } #endregion // -------------------------------------------------------------------- // GET: /ItRequestAPI/myRequests // -------------------------------------------------------------------- [HttpGet("myRequests")] public async Task MyRequests([FromQuery] string? status = null, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 50) { var userIdStr = _userManager.GetUserId(User); if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId)) return Unauthorized("Invalid user"); var q = _db.ItRequests .Join(_db.ItRequestStatus, r => r.ItRequestId, s => s.ItRequestId, (r, s) => new { r, s }) .Where(x => x.r.UserId == userId) .AsQueryable(); if (!string.IsNullOrWhiteSpace(status)) q = q.Where(x => x.s.OverallStatus == status); if (from.HasValue) q = q.Where(x => x.r.SubmitDate >= from.Value); if (to.HasValue) q = q.Where(x => x.r.SubmitDate < to.Value.AddDays(1)); var total = await q.CountAsync(); var data = await q .OrderByDescending(x => x.r.SubmitDate) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(x => new { x.r.ItRequestId, x.s.StatusId, x.r.StaffName, x.r.DepartmentName, x.r.CompanyName, x.r.RequiredDate, x.r.SubmitDate, OverallStatus = x.s.OverallStatus, // extra fields used by UI action buttons IsLockedForEdit = x.r.IsLockedForEdit, EditableUntil = x.r.EditableUntil, HodStatus = x.s.HodStatus, GitHodStatus = x.s.GitHodStatus, FinHodStatus = x.s.FinHodStatus, MgmtStatus = x.s.MgmtStatus }) .ToListAsync(); // post-shape with flags (kept in-memory to keep SQL simple) var shaped = data.Select(d => { var overall = d.OverallStatus ?? "Draft"; var remaining = d.EditableUntil.HasValue ? Math.Max(0, (int)(d.EditableUntil.Value - DateTime.UtcNow).TotalSeconds) : 0; bool isDraft = overall == "Draft"; bool canEditDraft = isDraft && !d.IsLockedForEdit && remaining > 0; bool allPending = (d.HodStatus ?? "Pending") == "Pending" && (d.GitHodStatus ?? "Pending") == "Pending" && (d.FinHodStatus ?? "Pending") == "Pending" && (d.MgmtStatus ?? "Pending") == "Pending"; bool canCancel = isDraft || (ALLOW_PENDING_CANCEL && overall == "Pending" && allPending); // optional: hint URL for your "View" button in Draft tab var editUrlHint = $"/IT/ApprovalDashboard/Edit?statusId={d.StatusId}"; return new { d.ItRequestId, d.StatusId, d.StaffName, d.DepartmentName, d.CompanyName, d.RequiredDate, d.SubmitDate, OverallStatus = overall, canEditDraft, canCancel, editUrlHint, remainingSeconds = remaining }; }).ToList(); return Ok(new { total, page, pageSize, data = shaped }); } // -------------------------------------------------------------------- // Flows CRUD // -------------------------------------------------------------------- [HttpGet("flows")] public async Task GetFlows() { var flows = await _db.ItApprovalFlows.ToListAsync(); return Ok(flows); } [HttpPost("flows")] public async Task CreateFlow([FromBody] ItApprovalFlow flow) { if (string.IsNullOrWhiteSpace(flow.FlowName)) return BadRequest(new { message = "Flow name is required" }); _db.ItApprovalFlows.Add(flow); await _db.SaveChangesAsync(); return Ok(new { message = "Flow created" }); } [HttpPut("flows/{id}")] public async Task UpdateFlow(int id, [FromBody] ItApprovalFlow updated) { var existing = await _db.ItApprovalFlows.FindAsync(id); if (existing == null) return NotFound(); existing.FlowName = updated.FlowName; existing.HodUserId = updated.HodUserId; existing.GroupItHodUserId = updated.GroupItHodUserId; existing.FinHodUserId = updated.FinHodUserId; existing.MgmtUserId = updated.MgmtUserId; await _db.SaveChangesAsync(); return Ok(new { message = "Flow updated" }); } [HttpDelete("flows/{id}")] public async Task DeleteFlow(int id) { var flow = await _db.ItApprovalFlows.FindAsync(id); if (flow == null) return NotFound(); bool inUse = await _db.ItRequestStatus.AnyAsync(s => s.ItApprovalFlowId == id); if (inUse) return BadRequest(new { message = "Cannot delete flow; it is in use." }); _db.ItApprovalFlows.Remove(flow); await _db.SaveChangesAsync(); return Ok(new { message = "Flow deleted" }); } // -------------------------------------------------------------------- // DTOs for create // -------------------------------------------------------------------- public class CreateItRequestDto { // UserId ignored by server (derived from token) public string StaffName { get; set; } = string.Empty; // client may send, server will override public string? CompanyName { get; set; } // override public string? DepartmentName { get; set; } // override public string? Designation { get; set; } public string? Location { get; set; } public string? EmploymentStatus { get; set; } public DateTime? ContractEndDate { get; set; } public DateTime RequiredDate { get; set; } public string? PhoneExt { get; set; } public List Hardware { get; set; } = new(); public List Emails { get; set; } = new(); public List OSReqs { get; set; } = new(); public List Software { get; set; } = new(); public List SharedPerms { get; set; } = new(); public int? EditWindowHours { get; set; } public bool? SendNow { get; set; } } public class HardwareDto { public string Category { get; set; } = ""; public string? Purpose { get; set; } public string? Justification { get; set; } public string? OtherDescription { get; set; } } // NOTE: as requested, Email UI only collects ProposedAddress. // Purpose/Notes will be stored as nulls. public class EmailDto { public string? ProposedAddress { get; set; } } public class OsreqDto { public string? RequirementText { get; set; } } public class SoftwareDto { public string Bucket { get; set; } = ""; // General | Utility | Custom public string Name { get; set; } = ""; public string? OtherName { get; set; } public string? Notes { get; set; } } public class SharedPermDto { public string? ShareName { get; set; } public bool CanRead { get; set; } public bool CanWrite { get; set; } public bool CanDelete { get; set; } public bool CanRemove { get; set; } } // -------------------------------------------------------------------- // POST: /ItRequestAPI/create // Creates a new IT Request and starts an edit window (default 24 hours) // -------------------------------------------------------------------- [HttpPost("create")] public async Task CreateRequest([FromBody] CreateItRequestDto dto) { if (dto == null) return BadRequest(new { message = "Invalid request payload" }); if (dto.RequiredDate == default) return BadRequest(new { message = "RequiredDate is required." }); // --- Server-side guard: RequiredDate >= today + 7 (local server date) --- var minDate = DateTime.Today.AddDays(7); if (dto.RequiredDate.Date < minDate) return BadRequest(new { message = $"RequiredDate must be at least {minDate:yyyy-MM-dd} or later." }); // --- Server-side guard: SharedPerms cap 6 (trim or reject; here we trim silently) --- if (dto.SharedPerms != null && dto.SharedPerms.Count > 6) dto.SharedPerms = dto.SharedPerms.Take(6).ToList(); // --- Optional normalization: avoid conflicting hardware choices --- // If DesktopAllIn is present, drop NotebookAllIn/NotebookOnly bool hasDesktopAllIn = dto.Hardware?.Any(h => string.Equals(h.Category, "DesktopAllIn", StringComparison.OrdinalIgnoreCase)) == true; if (hasDesktopAllIn && dto.Hardware != null) dto.Hardware = dto.Hardware.Where(h => !string.Equals(h.Category, "NotebookAllIn", StringComparison.OrdinalIgnoreCase) && !string.Equals(h.Category, "NotebookOnly", StringComparison.OrdinalIgnoreCase)) .ToList(); // If NotebookAllIn is present, drop DesktopAllIn/DesktopOnly bool hasNotebookAllIn = dto.Hardware?.Any(h => string.Equals(h.Category, "NotebookAllIn", StringComparison.OrdinalIgnoreCase)) == true; if (hasNotebookAllIn && dto.Hardware != null) dto.Hardware = dto.Hardware.Where(h => !string.Equals(h.Category, "DesktopAllIn", StringComparison.OrdinalIgnoreCase) && !string.Equals(h.Category, "DesktopOnly", StringComparison.OrdinalIgnoreCase)) .ToList(); try { // --- Validate and get current user ----------------------------------------------------- var userIdStr = _userManager.GetUserId(User); if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) return Unauthorized("Invalid user"); // --- Lookup user snapshot -------------------------------------------------------------- var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); if (user == null) return NotFound("User not found."); var dept = await _db.Departments.FirstOrDefaultAsync(d => d.DepartmentId == user.departmentId); var comp = dept != null ? await _db.Companies.FirstOrDefaultAsync(c => c.CompanyId == dept.CompanyId) : null; var snapStaffName = user.FullName ?? user.UserName ?? "(unknown)"; var snapDepartmentName = dept?.DepartmentName ?? ""; var snapCompanyName = comp?.CompanyName ?? ""; // --- Determine edit window hours ------------------------------------------------------- int windowHours = 24; if (dto.EditWindowHours.HasValue) windowHours = Math.Max(1, Math.Min(dto.EditWindowHours.Value, 24 * 14)); else if (int.TryParse(Request.Headers["X-Edit-Window-Hours"], out var h)) windowHours = Math.Max(1, Math.Min(h, 24 * 14)); else if (int.TryParse(Request.Query["editWindowHours"], out var q)) windowHours = Math.Max(1, Math.Min(q, 24 * 14)); var nowUtc = DateTime.UtcNow; // --- Insert main request --------------------------------------------------------------- var request = new ItRequest { UserId = currentUserId, StaffName = snapStaffName, DepartmentName = snapDepartmentName, CompanyName = snapCompanyName, Designation = dto.Designation, Location = dto.Location, EmploymentStatus = dto.EmploymentStatus, ContractEndDate = dto.ContractEndDate, RequiredDate = dto.RequiredDate, PhoneExt = dto.PhoneExt, SubmitDate = DateTime.Now, FirstSubmittedAt = nowUtc, EditableUntil = nowUtc.AddHours(windowHours), IsLockedForEdit = false }; _db.ItRequests.Add(request); await _db.SaveChangesAsync(); // --- Children -------------------------------------------------------------------------- if (dto.Hardware != null) foreach (var hw in dto.Hardware) _db.ItRequestHardwares.Add(new ItRequestHardware { ItRequestId = request.ItRequestId, Category = hw.Category, Purpose = hw.Purpose, Justification = hw.Justification, OtherDescription = hw.OtherDescription }); if (dto.Emails != null) foreach (var em in dto.Emails) _db.ItRequestEmails.Add(new ItRequestEmail { ItRequestId = request.ItRequestId, Purpose = null, ProposedAddress = em.ProposedAddress, Notes = null }); if (dto.OSReqs != null) foreach (var os in dto.OSReqs) _db.ItRequestOsRequirement.Add(new ItRequestOsRequirement { ItRequestId = request.ItRequestId, RequirementText = os.RequirementText }); if (dto.Software != null) foreach (var sw in dto.Software) _db.ItRequestSoftware.Add(new ItRequestSoftware { ItRequestId = request.ItRequestId, Bucket = sw.Bucket, Name = sw.Name, OtherName = sw.OtherName, Notes = sw.Notes }); if (dto.SharedPerms != null) foreach (var sp in dto.SharedPerms) _db.ItRequestSharedPermission.Add(new ItRequestSharedPermission { ItRequestId = request.ItRequestId, ShareName = sp.ShareName, CanRead = sp.CanRead, CanWrite = sp.CanWrite, CanDelete = sp.CanDelete, CanRemove = sp.CanRemove }); await _db.SaveChangesAsync(); // --- Default approval flow ------------------------------------------------------------- var flow = await _db.ItApprovalFlows.FirstOrDefaultAsync(); if (flow == null) return BadRequest(new { message = "No IT Approval Flow configured" }); var status = new ItRequestStatus { ItRequestId = request.ItRequestId, ItApprovalFlowId = flow.ItApprovalFlowId, HodStatus = "Pending", GitHodStatus = "Pending", FinHodStatus = "Pending", MgmtStatus = "Pending", OverallStatus = "Draft" }; _db.ItRequestStatus.Add(status); await _db.SaveChangesAsync(); if (dto.SendNow == true) { request.IsLockedForEdit = true; status.OverallStatus = "Pending"; await _db.SaveChangesAsync(); } return Ok(new { message = "IT request created successfully. You can edit it for a limited time.", requestId = request.ItRequestId, statusId = status.StatusId, editableUntil = request.EditableUntil, editWindowHours = windowHours, overallStatus = status.OverallStatus }); } catch (UnauthorizedAccessException ex) { return Unauthorized(ex.Message); } catch (Exception ex) { return StatusCode(500, new { message = "Error creating IT request", detail = ex.Message }); } } private static bool ShouldLock(ItRequest r) => !r.IsLockedForEdit && r.EditableUntil.HasValue && DateTime.UtcNow > r.EditableUntil.Value; private async Task LockOnlyAsync(ItRequest r) { if (r.IsLockedForEdit) return; r.IsLockedForEdit = true; await _db.SaveChangesAsync(); } // read current edit state (also lazy-lock if expired) [HttpGet("editWindow/{statusId:int}")] public async Task GetEditWindow(int statusId) { var s = await _db.ItRequestStatus.Include(x => x.Request).FirstOrDefaultAsync(x => x.StatusId == statusId); if (s == null) return NotFound("Status not found"); var r = s.Request; if (ShouldLock(r)) await LockOnlyAsync(r); var remaining = r.EditableUntil.HasValue ? Math.Max(0, (int)(r.EditableUntil.Value - DateTime.UtcNow).TotalSeconds) : 0; return Ok(new { overallStatus = s.OverallStatus ?? "Draft", isEditable = !r.IsLockedForEdit && (s.OverallStatus ?? "Draft") == "Draft" && remaining > 0, remainingSeconds = remaining, editableUntil = r.EditableUntil }); } // PUT: full draft update (same shape as create) public class UpdateDraftDto : CreateItRequestDto { public int StatusId { get; set; } } [HttpPut("edit/{statusId:int}")] public async Task UpdateDraft(int statusId, [FromBody] UpdateDraftDto dto) { int currentUserId; try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } var s = await _db.ItRequestStatus.Include(x => x.Request).FirstOrDefaultAsync(x => x.StatusId == statusId); if (s == null) return NotFound("Status not found"); var r = s.Request; if (r.UserId != currentUserId) return Unauthorized("You can only edit your own request"); if (ShouldLock(r)) await LockOnlyAsync(r); if (r.IsLockedForEdit || (s.OverallStatus ?? "Draft") != "Draft") return StatusCode(423, "Edit window closed."); // --- Server-side guard: RequiredDate >= today + 7 --- var minDate = DateTime.Today.AddDays(7); if (dto.RequiredDate.Date < minDate) return BadRequest(new { message = $"RequiredDate must be at least {minDate:yyyy-MM-dd} or later." }); // --- Cap shared perms to 6 --- if (dto.SharedPerms != null && dto.SharedPerms.Count > 6) dto.SharedPerms = dto.SharedPerms.Take(6).ToList(); // --- Optional normalization of conflicting hardware --- bool hasDesktopAllIn = dto.Hardware?.Any(h => string.Equals(h.Category, "DesktopAllIn", StringComparison.OrdinalIgnoreCase)) == true; if (hasDesktopAllIn && dto.Hardware != null) dto.Hardware = dto.Hardware.Where(h => !string.Equals(h.Category, "NotebookAllIn", StringComparison.OrdinalIgnoreCase) && !string.Equals(h.Category, "NotebookOnly", StringComparison.OrdinalIgnoreCase)).ToList(); bool hasNotebookAllIn = dto.Hardware?.Any(h => string.Equals(h.Category, "NotebookAllIn", StringComparison.OrdinalIgnoreCase)) == true; if (hasNotebookAllIn && dto.Hardware != null) dto.Hardware = dto.Hardware.Where(h => !string.Equals(h.Category, "DesktopAllIn", StringComparison.OrdinalIgnoreCase) && !string.Equals(h.Category, "DesktopOnly", StringComparison.OrdinalIgnoreCase)).ToList(); // update simple fields r.Designation = dto.Designation; r.Location = dto.Location; r.EmploymentStatus = dto.EmploymentStatus; r.ContractEndDate = dto.ContractEndDate; r.RequiredDate = dto.RequiredDate; r.PhoneExt = dto.PhoneExt; // replace children var id = r.ItRequestId; _db.ItRequestHardwares.RemoveRange(_db.ItRequestHardwares.Where(x => x.ItRequestId == id)); if (dto.Hardware != null) foreach (var x in dto.Hardware) _db.ItRequestHardwares.Add(new ItRequestHardware { ItRequestId = id, Category = x.Category, Purpose = x.Purpose, Justification = x.Justification, OtherDescription = x.OtherDescription }); _db.ItRequestEmails.RemoveRange(_db.ItRequestEmails.Where(x => x.ItRequestId == id)); if (dto.Emails != null) foreach (var x in dto.Emails) _db.ItRequestEmails.Add(new ItRequestEmail { ItRequestId = id, Purpose = null, ProposedAddress = x.ProposedAddress, Notes = null }); _db.ItRequestOsRequirement.RemoveRange(_db.ItRequestOsRequirement.Where(x => x.ItRequestId == id)); if (dto.OSReqs != null) foreach (var x in dto.OSReqs) _db.ItRequestOsRequirement.Add(new ItRequestOsRequirement { ItRequestId = id, RequirementText = x.RequirementText }); _db.ItRequestSoftware.RemoveRange(_db.ItRequestSoftware.Where(x => x.ItRequestId == id)); if (dto.Software != null) foreach (var x in dto.Software) _db.ItRequestSoftware.Add(new ItRequestSoftware { ItRequestId = id, Bucket = x.Bucket, Name = x.Name, OtherName = x.OtherName, Notes = x.Notes }); _db.ItRequestSharedPermission.RemoveRange(_db.ItRequestSharedPermission.Where(x => x.ItRequestId == id)); if (dto.SharedPerms != null) foreach (var x in dto.SharedPerms) _db.ItRequestSharedPermission.Add(new ItRequestSharedPermission { ItRequestId = id, ShareName = x.ShareName, CanRead = x.CanRead, CanWrite = x.CanWrite, CanDelete = x.CanDelete, CanRemove = x.CanRemove }); await _db.SaveChangesAsync(); var remaining = r.EditableUntil.HasValue ? Math.Max(0, (int)(r.EditableUntil.Value - DateTime.UtcNow).TotalSeconds) : 0; return Ok(new { message = "Draft saved", remainingSeconds = remaining }); } // POST: send early [HttpPost("sendNow/{statusId:int}")] public async Task SendNow(int statusId) { int uid; try { uid = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } var s = await _db.ItRequestStatus .Include(x => x.Request) .FirstOrDefaultAsync(x => x.StatusId == statusId); if (s == null) return NotFound("Status not found"); if (s.Request.UserId != uid) return Unauthorized("Only owner can send"); // Allow SendNow as long as it’s still a Draft (even if the edit window already locked it). if ((s.OverallStatus ?? "Draft") != "Draft") return BadRequest(new { message = "Already sent or not in Draft." }); // Ensure locked, then make it visible to approvers. if (!s.Request.IsLockedForEdit) s.Request.IsLockedForEdit = true; s.OverallStatus = "Pending"; await _db.SaveChangesAsync(); return Ok(new { message = "Sent to approval", overallStatus = s.OverallStatus }); } // -------------------------------------------------------------------- // GET: /ItRequestAPI/request/{statusId} // Adds lazy-lock and returns full request details for Edit page // -------------------------------------------------------------------- [HttpGet("request/{statusId}")] public async Task GetRequestDetail(int statusId) { // auth var userIdStr = _userManager.GetUserId(User); if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) return Unauthorized("Invalid user"); // load status + children var status = await _db.ItRequestStatus .Include(s => s.Request).ThenInclude(r => r!.Hardware) .Include(s => s.Request).ThenInclude(r => r!.Emails) .Include(s => s.Request).ThenInclude(r => r!.OsRequirements) .Include(s => s.Request).ThenInclude(r => r!.Software) .Include(s => s.Request).ThenInclude(r => r!.SharedPermissions) .Include(s => s.Flow) .FirstOrDefaultAsync(s => s.StatusId == statusId); if (status == null) return NotFound("Request not found"); // lazy lock if the edit window has expired async Task LazyLockIfExpired(ItRequestStatus s) { var r = s.Request; var expired = !r.IsLockedForEdit && r.EditableUntil.HasValue && DateTime.UtcNow > r.EditableUntil.Value; if (!expired) return; r.IsLockedForEdit = true; await _db.SaveChangesAsync(); } await LazyLockIfExpired(status); // remaining seconds for countdown var remaining = status.Request.EditableUntil.HasValue ? Math.Max(0, (int)(status.Request.EditableUntil.Value - DateTime.UtcNow).TotalSeconds) : 0; // role + canApprove (unchanged) string role = status.Flow.HodUserId == currentUserId ? "HOD" : status.Flow.GroupItHodUserId == currentUserId ? "GIT_HOD" : status.Flow.FinHodUserId == currentUserId ? "FIN_HOD" : status.Flow.MgmtUserId == currentUserId ? "MGMT" : "VIEWER"; string currentUserStatus = "N/A"; bool canApprove = false; if (string.Equals(status.OverallStatus, "Cancelled", StringComparison.OrdinalIgnoreCase) || string.Equals(status.OverallStatus ?? "Draft", "Draft", StringComparison.OrdinalIgnoreCase)) { canApprove = false; } if (role == "HOD") { currentUserStatus = status.HodStatus ?? "Pending"; canApprove = currentUserStatus == "Pending"; } else if (role == "GIT_HOD") { currentUserStatus = status.GitHodStatus ?? "Pending"; canApprove = currentUserStatus == "Pending" && status.HodStatus == "Approved"; } else if (role == "FIN_HOD") { currentUserStatus = status.FinHodStatus ?? "Pending"; canApprove = currentUserStatus == "Pending" && status.HodStatus == "Approved" && status.GitHodStatus == "Approved"; } else if (role == "MGMT") { currentUserStatus = status.MgmtStatus ?? "Pending"; canApprove = currentUserStatus == "Pending" && status.HodStatus == "Approved" && status.GitHodStatus == "Approved" && status.FinHodStatus == "Approved"; } // No actions allowed on cancelled requests // RESPONSE: include full main fields for Edit page return Ok(new { // full request fields you need to prefill request = new { status.Request.ItRequestId, status.Request.StaffName, status.Request.DepartmentName, status.Request.CompanyName, status.Request.Designation, status.Request.Location, status.Request.EmploymentStatus, status.Request.ContractEndDate, status.Request.RequiredDate, status.Request.PhoneExt, status.Request.SubmitDate }, approverRole = role, hardware = status.Request.Hardware?.Select(h => new { h.Id, Category = h.Category ?? "", Purpose = h.Purpose ?? "", Justification = h.Justification ?? "", OtherDescription = h.OtherDescription ?? "" }), emails = status.Request.Emails?.Select(e => new { e.Id, Purpose = e.Purpose ?? "", ProposedAddress = e.ProposedAddress ?? "", Notes = e.Notes ?? "" }), osreqs = status.Request.OsRequirements?.Select(o => new { o.Id, RequirementText = o.RequirementText ?? "" }), software = status.Request.Software?.Select(sw => new { sw.Id, Bucket = sw.Bucket ?? "", Name = sw.Name ?? "", OtherName = sw.OtherName ?? "", Notes = sw.Notes ?? "" }), sharedPerms = status.Request.SharedPermissions?.Select(sp => new { sp.Id, ShareName = sp.ShareName ?? "", CanRead = sp.CanRead, CanWrite = sp.CanWrite, CanDelete = sp.CanDelete, CanRemove = sp.CanRemove }), status = new { hodStatus = status.HodStatus ?? "Pending", gitHodStatus = status.GitHodStatus ?? "Pending", finHodStatus = status.FinHodStatus ?? "Pending", mgmtStatus = status.MgmtStatus ?? "Pending", hodSubmitDate = status.HodSubmitDate, gitHodSubmitDate = status.GitHodSubmitDate, finHodSubmitDate = status.FinHodSubmitDate, mgmtSubmitDate = status.MgmtSubmitDate, overallStatus = status.OverallStatus ?? "Pending", canApprove = canApprove, currentUserStatus = currentUserStatus }, // edit window info for the Edit page edit = new { overallStatus = status.OverallStatus ?? "Draft", isEditable = !status.Request.IsLockedForEdit && (status.OverallStatus ?? "Draft") == "Draft" && remaining > 0, remainingSeconds = remaining, editableUntil = status.Request.EditableUntil } }); } // -------------------------------------------------------------------- // GET: /ItRequestAPI/lookups // -------------------------------------------------------------------- [HttpGet("lookups")] public IActionResult Lookups() { var hardwareCategories = new[] { "DesktopAllIn","NotebookAllIn","DesktopOnly","NotebookOnly","NotebookBattery","PowerAdapter","Mouse","ExternalHDD","Other" }; var hardwarePurposes = new[] { "NewRecruitment", "Replacement", "Additional" }; var employmentStatuses = new[] { "Permanent", "Contract", "Temp", "New Staff" }; var softwareBuckets = new[] { "General", "Utility", "Custom" }; return Ok(new { hardwareCategories, hardwarePurposes, employmentStatuses, softwareBuckets }); } // -------------------------------------------------------------------- // POST: /ItRequestAPI/cancel public class CancelDto { public int RequestId { get; set; } public string? Reason { get; set; } } [HttpPost("cancel")] public async Task CancelRequest([FromBody] CancelDto dto) { int userId; try { userId = GetCurrentUserIdOrThrow(); } catch (UnauthorizedAccessException ex) { return Unauthorized(ex.Message); } var req = await _db.ItRequests.FirstOrDefaultAsync(r => r.ItRequestId == dto.RequestId); if (req == null) return NotFound("Request not found"); if (req.UserId != userId) return Unauthorized("You can only cancel your own requests"); var s = await _db.ItRequestStatus.FirstOrDefaultAsync(x => x.ItRequestId == req.ItRequestId); if (s == null) return NotFound("Status row not found"); var overall = s.OverallStatus ?? "Draft"; // Allow cancel: // - Draft (always) // - Pending (only if ALLOW_PENDING_CANCEL == true) and no approvals have started bool allPending = (s.HodStatus ?? "Pending") == "Pending" && (s.GitHodStatus ?? "Pending") == "Pending" && (s.FinHodStatus ?? "Pending") == "Pending" && (s.MgmtStatus ?? "Pending") == "Pending"; bool canCancelNow = overall == "Draft" || (ALLOW_PENDING_CANCEL && overall == "Pending" && allPending); if (!canCancelNow) return BadRequest(new { message = "Cannot cancel after approvals have started or once decided." }); s.OverallStatus = "Cancelled"; req.IsLockedForEdit = true; // TODO: persist dto.Reason if you add a column await _db.SaveChangesAsync(); return Ok(new { message = "Request cancelled", requestId = req.ItRequestId, statusId = s.StatusId, overallStatus = s.OverallStatus }); } } }