PSTW_CentralizeSystem/Controllers/API/ITRequestAPI.cs
2025-11-10 15:25:14 +08:00

1813 lines
80 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<UserModel> _userManager;
public ItRequestAPI(CentralSystemContext db, UserManager<UserModel> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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,
RevNo = status.StatusId.ToString(),
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");
}
// ===== 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<IActionResult> 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<object>() });
// 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<IActionResult> 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<string> 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 cant 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetItTeam() =>
Ok(await _db.ItTeamMembers.Select(t => t.UserId).ToListAsync());
// POST: /ItRequestAPI/itTeam body: { userIds: [1,2,3] }
public class ItTeamDto { public List<int> UserIds { get; set; } = new(); }
[HttpPost("itTeam")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<string>(), data = Array.Empty<object>() });
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
{
// dont 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<IActionResult> 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 isnt 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<IActionResult> 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<IActionResult> GetFlows()
{
var flows = await _db.ItApprovalFlows.ToListAsync();
return Ok(flows);
}
[HttpPost("flows")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<HardwareDto> Hardware { get; set; } = new();
public List<EmailDto> Emails { get; set; } = new();
public List<OsreqDto> OSReqs { get; set; } = new();
public List<SoftwareDto> Software { get; set; } = new();
public List<SharedPermDto> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 its 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<IActionResult> 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<IActionResult> 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 });
}
}
}