using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using PSTW_CentralSystem.Areas.Bookings.Models; using PSTW_CentralSystem.DBContext; using System.ComponentModel.DataAnnotations; using System.Text.Json; using System.Text.Json.Serialization; namespace PSTW_CentralSystem.Controllers.API { [ApiController] [Route("api/BookingsApi")] [Authorize] // Require auth by default public class BookingsAPIController : ControllerBase { private readonly CentralSystemContext _db; private readonly ILogger _logger; private static readonly TimeZoneInfo AppTz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Kuala_Lumpur"); public BookingsAPIController(CentralSystemContext db, ILogger logger) { _db = db; _logger = logger; } private int? GetCurrentUserId() { var idStr = User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; return int.TryParse(idStr, out var id) ? id : (int?)null; } private bool IsManager() { var me = GetCurrentUserId(); if (me is null) return false; // Dynamic manager list (booking_managers) return _db.BookingManager.AsNoTracking() .Any(x => x.UserId == me.Value && x.IsActive); } private bool CanModify(Booking b, int? currentUserId) { if (b is null || currentUserId is null) return false; if (IsManager()) return true; // Managers: can modify any // Owner OR target may modify return b.RequestedByUserId == currentUserId || b.TargetUserId == currentUserId; } #region Undo helpers private static bool IsTruthy(string? s) { if (string.IsNullOrWhiteSpace(s)) return false; var v = s.Trim(); return v == "1" || v.Equals("true", StringComparison.OrdinalIgnoreCase) || v.Equals("yes", StringComparison.OrdinalIgnoreCase) || v.Equals("y", StringComparison.OrdinalIgnoreCase) || v.Equals("on", StringComparison.OrdinalIgnoreCase); } private static bool IsSameLocalDate(DateTime aUtc, DateTime bUtc, TimeZoneInfo tz) { var a = TimeZoneInfo.ConvertTimeFromUtc(aUtc, tz).Date; var b = TimeZoneInfo.ConvertTimeFromUtc(bUtc, tz).Date; return a == b; } private bool ShouldUndo(System.Text.Json.JsonElement? body) { // query string: /api/BookingsApi?action=cancel&id=1&undo=1 if (Request.Query.TryGetValue("undo", out var qv)) { string? s = qv.Count > 0 ? qv[0].ToString() : null; if (IsTruthy(s)) return true; } // JSON body: { "undo": true } or { "uncancel": true } or { "restore": true } if (body.HasValue && body.Value.ValueKind == System.Text.Json.JsonValueKind.Object) { var root = body.Value; if ((root.TryGetProperty("undo", out var a) && a.ValueKind == System.Text.Json.JsonValueKind.True) || (root.TryGetProperty("uncancel", out var b) && b.ValueKind == System.Text.Json.JsonValueKind.True) || (root.TryGetProperty("unCancel", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.True) || (root.TryGetProperty("restore", out var d) && d.ValueKind == System.Text.Json.JsonValueKind.True)) return true; } return false; } #endregion private static string? GetBodyNote(in JsonElement root) => root.TryGetProperty("note", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null; // ------------------- Helpers ------------------- private static DateTime CoerceToUtc(DateTime dt) { return dt.Kind switch { DateTimeKind.Utc => dt, DateTimeKind.Unspecified => DateTime.SpecifyKind(dt, DateTimeKind.Utc), _ => dt.ToUniversalTime() }; } private static bool IsSameUtcDate(DateTime a, DateTime b) => a.Date == b.Date; private static JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; private static DateTime SnapToNearest30(DateTime utc) { var minutes = utc.Minute; var baseTime = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc); if (minutes < 15) return baseTime; // round down if (minutes < 45) return baseTime.AddMinutes(30); // round to :30 return baseTime.AddHours(1); // round up to next hour } // ------------------- DTOs ------------------- public record CreateBookingDto( string Title, string? Purpose, [property: JsonPropertyName("description")] string? Description, // alias int RoomId, DateTime StartUtc, DateTime EndUtc, int RequestedByUserId, int? TargetUserId, string? Note, [property: JsonPropertyName("notes")] string? Notes // alias ); public record UpdateBookingDto( string? Title, string? Purpose, [property: JsonPropertyName("description")] string? Description, // alias int? RoomId, DateTime? StartUtc, DateTime? EndUtc, string? Note, [property: JsonPropertyName("notes")] string? Notes // alias ); public record CreateRoomDto(string RoomName, string? LocationCode, int? Capacity, bool IsActive = true); public record UpdateRoomDto(string? RoomName, string? LocationCode, int? Capacity, bool? IsActive); // GET /api/BookingsApi/users [HttpGet("users")] public async Task UsersAsync() { var list = await _db.Users .AsNoTracking() .OrderBy(u => u.FullName) .Select(u => new { id = u.Id, name = u.FullName, email = u.Email }) .ToListAsync(); return Ok(list); } // GET /api/BookingsApi/managers -> [1,2,3] [HttpGet("managers")] public async Task GetManagersAsync() { var ids = await _db.BookingManager .AsNoTracking() .Where(x => x.IsActive) .Select(x => x.UserId) .ToListAsync(); return Ok(ids); } public class SaveManagersDto { public List? UserIds { get; set; } } // POST /api/BookingsApi/managers body: { "userIds":[1,2,3] } [HttpPost("managers")] public async Task SaveManagersAsync([FromBody] SaveManagersDto dto) { if (dto?.UserIds is null) return BadRequest("userIds required."); // Only an existing manager can edit the list (bootstrap by seeding one row) if (!IsManager()) return Unauthorized("Managers only."); var now = DateTime.UtcNow; var me = GetCurrentUserId(); // Soft-reset approach: mark all inactive then upsert selected as active var all = await _db.BookingManager.ToListAsync(); foreach (var bm in all) bm.IsActive = false; var selected = new HashSet(dto.UserIds.Where(id => id > 0)); foreach (var uid in selected) { var existing = all.FirstOrDefault(x => x.UserId == uid); if (existing is null) { _db.BookingManager.Add(new BookingManager { UserId = uid, IsActive = true, CreatedUtc = now, CreatedByUserId = me }); } else { existing.IsActive = true; } } await _db.SaveChangesAsync(); return Ok(new { saved = true, count = selected.Count }); } // ========================================================= // ========================= GET =========================== // ========================================================= // - Bookings list: GET /api/BookingsApi // - Booking details: GET /api/BookingsApi?id=123 // - Lookups (rooms+users) GET /api/BookingsApi?lookups=1 // - Rooms list: GET /api/BookingsApi?scope=rooms[&includeInactive=true] [HttpGet] public async Task GetAsync( [FromQuery] int? id, [FromQuery] int? lookups, [FromQuery] string? scope, [FromQuery] bool includeInactive = true, [FromQuery] string? search = null, [FromQuery] string? status = null, [FromQuery] DateTime? from = null, [FromQuery] DateTime? to = null, [FromQuery] int? roomId = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 450, [FromQuery] int? userId = null, [FromQuery] int? companyId = null, [FromQuery] int? departmentId = null ) { // ROOMS LIST (admin tooling / open to authenticated) if (string.Equals(scope, "rooms", StringComparison.OrdinalIgnoreCase)) { var rq = _db.Rooms.AsNoTracking().AsQueryable(); if (!includeInactive) rq = rq.Where(r => r.IsActive); var rooms = await rq .OrderBy(r => r.RoomName) .Select(r => new { roomId = r.RoomId, roomName = r.RoomName, locationCode = r.LocationCode, capacity = r.Capacity, isActive = r.IsActive }) .ToListAsync(); return Ok(rooms); } // ---------- CALENDAR LOOKUPS (everyone sees the full lists) ---------- // GET /api/BookingsApi?scope=calendar&lookups=1 if (string.Equals(scope, "calendar", StringComparison.OrdinalIgnoreCase) && lookups == 1) { var roomsAll = await _db.Rooms .AsNoTracking() .Where(r => r.IsActive) .OrderBy(r => r.RoomName) .Select(r => new { roomId = r.RoomId, roomName = r.RoomName }) .ToListAsync(); var usersAll = await _db.Users .AsNoTracking() .OrderBy(u => u.FullName) .Select(u => new { u.Id, UserName = u.FullName, u.Email }) .ToListAsync(); return Ok(new { rooms = roomsAll, users = usersAll }); } // ---------- CALENDAR LIST (everyone sees all bookings) ---------- // GET /api/BookingsApi?scope=calendar&from=...&to=... if (string.Equals(scope, "calendar", StringComparison.OrdinalIgnoreCase) && !id.HasValue && lookups != 1) { var qCal = _db.Bookings.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) qCal = qCal.Where(x => x.Title.Contains(search)); if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, true, out var stCal)) qCal = qCal.Where(x => x.CurrentStatus == stCal); DateTime? fromUtcCal = from.HasValue ? CoerceToUtc(from.Value) : null; DateTime? toUtcCal = to.HasValue ? CoerceToUtc(to.Value) : null; if (fromUtcCal.HasValue) qCal = qCal.Where(x => x.EndUtc > fromUtcCal.Value); if (toUtcCal.HasValue) qCal = qCal.Where(x => x.StartUtc < toUtcCal.Value); if (roomId.HasValue) qCal = qCal.Where(x => x.RoomId == roomId.Value); if (userId.HasValue && userId.Value > 0) qCal = qCal.Where(x => x.RequestedByUserId == userId.Value || x.TargetUserId == userId.Value); if (companyId.HasValue && companyId.Value > 0) qCal = qCal.Where(x => x.CompanyId == companyId.Value); if (departmentId.HasValue && departmentId.Value > 0) qCal = qCal.Where(x => x.DepartmentId == departmentId.Value); var list = await qCal .OrderBy(x => x.StartUtc) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(b => new { id = b.BookingId, roomId = b.RoomId, requestedByUserId = b.RequestedByUserId, targetUserId = b.TargetUserId, title = b.Title, description = b.Purpose, startUtc = DateTime.SpecifyKind(b.StartUtc, DateTimeKind.Utc), endUtc = DateTime.SpecifyKind(b.EndUtc, DateTimeKind.Utc), status = b.CurrentStatus.ToString(), note = b.Note }) .ToListAsync(); return Ok(list); } // LOOKUPS PACK if (lookups == 1) { var rooms = await _db.Rooms .AsNoTracking() .Where(r => r.IsActive) .OrderBy(r => r.RoomName) .Select(r => new { roomId = r.RoomId, roomName = r.RoomName }) .ToListAsync(); object usersPayload; if (IsManager()) { usersPayload = await _db.Users .AsNoTracking() .OrderBy(u => u.FullName) .Select(u => new { u.Id, UserName = u.FullName, u.Email }) .ToListAsync(); } else { var me = GetCurrentUserId(); usersPayload = (me is int myId) ? await _db.Users.AsNoTracking() .Where(u => u.Id == myId) .Select(u => new { u.Id, UserName = u.FullName, u.Email }) .ToListAsync() : new object[0]; } return Ok(new { rooms, users = usersPayload }); } // BOOKING DETAILS if (id.HasValue) { var b = await _db.Bookings.AsNoTracking().FirstOrDefaultAsync(x => x.BookingId == id.Value); if (b is null) return NotFound(); var me = GetCurrentUserId(); if (!IsManager() && me != b.RequestedByUserId && me != b.TargetUserId) return Unauthorized("Not allowed to view this booking."); return Ok(new { id = b.BookingId, roomId = b.RoomId, requestedByUserId = b.RequestedByUserId, title = b.Title, description = b.Purpose, startUtc = DateTime.SpecifyKind(b.StartUtc, DateTimeKind.Utc), endUtc = DateTime.SpecifyKind(b.EndUtc, DateTimeKind.Utc), status = b.CurrentStatus.ToString(), note = b.Note }); } // BOOKINGS LIST var q = _db.Bookings.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) q = q.Where(x => x.Title.Contains(search)); if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, true, out var st)) q = q.Where(x => x.CurrentStatus == st); DateTime? fromUtc = from.HasValue ? CoerceToUtc(from.Value) : null; DateTime? toUtc = to.HasValue ? CoerceToUtc(to.Value) : null; if (fromUtc.HasValue) q = q.Where(x => x.EndUtc > fromUtc.Value); if (toUtc.HasValue) q = q.Where(x => x.StartUtc < toUtc.Value); if (roomId.HasValue) q = q.Where(x => x.RoomId == roomId.Value); if (userId.HasValue && userId.Value > 0) q = q.Where(x => x.RequestedByUserId == userId.Value || x.TargetUserId == userId.Value); if (companyId.HasValue && companyId.Value > 0) q = q.Where(x => x.CompanyId == companyId.Value); if (departmentId.HasValue && departmentId.Value > 0) q = q.Where(x => x.DepartmentId == departmentId.Value); // RBAC: non-managers only see their own var meList = GetCurrentUserId(); if (!IsManager()) { if (meList is int myId) q = q.Where(x => x.RequestedByUserId == myId || x.TargetUserId == myId); else return Unauthorized(); } var items = await q.OrderBy(x => x.StartUtc) .Skip((page - 1) * pageSize) .Take(pageSize) .Select(b => new { id = b.BookingId, roomId = b.RoomId, requestedByUserId = b.RequestedByUserId, title = b.Title, description = b.Purpose, startUtc = DateTime.SpecifyKind(b.StartUtc, DateTimeKind.Utc), endUtc = DateTime.SpecifyKind(b.EndUtc, DateTimeKind.Utc), status = b.CurrentStatus.ToString(), note = b.Note }) .ToListAsync(); return Ok(items); } // ========================================================= // ======================== POST =========================== // ========================================================= // - Create booking: POST /api/BookingsApi // - Cancel/Un-cancel: POST /api/BookingsApi?action=cancel&id=123 (body: { "undo": true }) // - Create room: POST /api/BookingsApi?scope=rooms [HttpPost] public async Task PostAsync( [FromQuery] string? action, [FromQuery] int? id, [FromQuery] string? scope, [FromBody] System.Text.Json.JsonElement? body // nullable is fine ) { var json = body?.ToString() ?? "{}"; // ROOMS: CREATE (Managers only) if (string.Equals(scope, "rooms", StringComparison.OrdinalIgnoreCase)) { if (!IsManager()) return Unauthorized("Managers only."); var dto = JsonSerializer.Deserialize(json, JsonOpts); if (dto is null || string.IsNullOrWhiteSpace(dto.RoomName)) return BadRequest("RoomName is required."); var exists = await _db.Rooms.AnyAsync(r => r.RoomName == dto.RoomName); if (exists) return Conflict("A room with this name already exists."); var room = new Room { RoomName = dto.RoomName.Trim(), LocationCode = string.IsNullOrWhiteSpace(dto.LocationCode) ? null : dto.LocationCode.Trim(), Capacity = dto.Capacity, IsActive = dto.IsActive }; _db.Rooms.Add(room); await _db.SaveChangesAsync(); return Ok(new { roomId = room.RoomId }); } // BOOKING: APPROVE (Manager only) if (string.Equals(action, "approve", StringComparison.OrdinalIgnoreCase)) { if (!id.HasValue || id <= 0) return BadRequest("Missing id."); if (!IsManager()) return Unauthorized("Not authorized to approve bookings."); var b = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); if (b is null) return NotFound(); if (b.CurrentStatus == BookingStatus.Cancelled) return Conflict("Cannot approve a cancelled booking."); if (b.CurrentStatus == BookingStatus.Approved) return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); // prevent overlap with other APPROVED bookings var overlap = await _db.Bookings.AsNoTracking().AnyAsync(x => x.RoomId == b.RoomId && x.BookingId != b.BookingId && x.CurrentStatus == BookingStatus.Approved && x.StartUtc < b.EndUtc && b.StartUtc < x.EndUtc); if (overlap) return Conflict("Cannot approve: time slot overlaps an approved booking."); b.CurrentStatus = BookingStatus.Approved; b.LastUpdatedUtc = DateTime.UtcNow; await _db.SaveChangesAsync(); return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); } // BOOKING: REJECT / UN-REJECT (Manager only; undo -> Pending) if (string.Equals(action, "reject", StringComparison.OrdinalIgnoreCase)) { if (!id.HasValue || id <= 0) return BadRequest("Missing id."); if (!IsManager()) return Unauthorized("Not authorized to reject bookings."); var b = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); if (b is null) return NotFound(); bool undo = ShouldUndo(body); if (!undo) { if (b.CurrentStatus == BookingStatus.Cancelled) return Conflict("Cannot reject a cancelled booking."); if (body.HasValue && body.Value.ValueKind == JsonValueKind.Object) b.Note = GetBodyNote(body.Value); b.CurrentStatus = BookingStatus.Rejected; } else { if (b.CurrentStatus != BookingStatus.Rejected) return Conflict("Booking is not rejected."); b.CurrentStatus = BookingStatus.Pending; } b.LastUpdatedUtc = DateTime.UtcNow; await _db.SaveChangesAsync(); return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); } // ----- BOOKING: CANCEL / UN-CANCEL (Owner or Manager) ------------------- if (string.Equals(action, "cancel", StringComparison.OrdinalIgnoreCase)) { if (!id.HasValue || id <= 0) return BadRequest("Missing id."); var b = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); if (b is null) return NotFound(); var me = GetCurrentUserId(); if (!CanModify(b, me)) return Unauthorized("Only the creator or a manager can cancel/un-cancel this booking."); if (b.CurrentStatus == BookingStatus.Rejected) return Conflict("Rejected bookings cannot be cancelled or un-cancelled."); bool undo = ShouldUndo(body); if (!undo) { if (b.CurrentStatus != BookingStatus.Cancelled) { b.CurrentStatus = BookingStatus.Cancelled; b.LastUpdatedUtc = DateTime.UtcNow; await _db.SaveChangesAsync(); } return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); } else { if (b.CurrentStatus != BookingStatus.Cancelled) return Conflict("Booking is not cancelled."); var overlap = await _db.Bookings.AsNoTracking().AnyAsync(x => x.RoomId == b.RoomId && x.BookingId != b.BookingId && (x.CurrentStatus == BookingStatus.Pending || x.CurrentStatus == BookingStatus.Approved) && x.StartUtc < b.EndUtc && b.StartUtc < x.EndUtc); if (overlap) return Conflict("Cannot un-cancel: time slot now overlaps another booking."); b.CurrentStatus = BookingStatus.Pending; b.LastUpdatedUtc = DateTime.UtcNow; await _db.SaveChangesAsync(); return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); } } // ------------------ BOOKING: CREATE ------------------------- var create = JsonSerializer.Deserialize(json, JsonOpts); if (create is null) return BadRequest("Invalid payload."); // For non-managers, force ownership to the current user var meCreate = GetCurrentUserId(); if (!IsManager()) { if (meCreate is null) return Unauthorized(); create = create with { RequestedByUserId = meCreate.Value, TargetUserId = create.TargetUserId ?? meCreate.Value }; } var roomOk = await _db.Rooms.AnyAsync(r => r.RoomId == create.RoomId && r.IsActive); if (!roomOk) return BadRequest("Room not found or inactive."); var targetUserId = create.TargetUserId ?? create.RequestedByUserId; var u = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == targetUserId); if (u == null) return BadRequest("Selected user not found."); int? departmentId = u.departmentId; int? companyId = null; if (departmentId is int depId) { companyId = await _db.Departments.AsNoTracking() .Where(d => d.DepartmentId == depId) .Select(d => d.CompanyId) .FirstOrDefaultAsync(); } var purpose = (create.Purpose ?? create.Description)?.Trim(); var noteIn = (create.Note ?? create.Notes)?.Trim(); // normalize + snap to 30 var startUtc = SnapToNearest30(CoerceToUtc(create.StartUtc)); var endUtc = SnapToNearest30(CoerceToUtc(create.EndUtc)); // guards if (endUtc <= startUtc) return BadRequest("End time must be after Start time."); if (!IsSameLocalDate(startUtc, endUtc, AppTz)) return BadRequest("End must be on the same calendar date as Start."); var localStart = TimeZoneInfo.ConvertTimeFromUtc(startUtc, AppTz); var localEnd = TimeZoneInfo.ConvertTimeFromUtc(endUtc, AppTz); if (localStart.TimeOfDay < new TimeSpan(8, 0, 0)) return BadRequest("Start must be at or after 08:00."); if (localEnd.TimeOfDay > new TimeSpan(20, 0, 0)) return BadRequest("End must be at or before 20:00."); // NEW: disallow creating bookings in the past (rounded up to next :00 / :30) var nowUtcCeil = SnapToNearest30(DateTime.UtcNow.AddSeconds(1)); // ceil to next 30-min tick if (startUtc < nowUtcCeil) return BadRequest("Start must be in the future."); var entity = new Booking { RequestedByUserId = create.RequestedByUserId, TargetUserId = create.TargetUserId ?? create.RequestedByUserId, DepartmentId = departmentId, CompanyId = companyId, RoomId = create.RoomId, Title = create.Title?.Trim() ?? "", Purpose = string.IsNullOrWhiteSpace(purpose) ? null : purpose, StartUtc = startUtc, EndUtc = endUtc, Note = string.IsNullOrWhiteSpace(noteIn) ? null : noteIn, CreatedUtc = DateTime.UtcNow, LastUpdatedUtc = DateTime.UtcNow, CurrentStatus = BookingStatus.Pending }; // data annotations var vc = new ValidationContext(entity); var vr = new List(); if (!Validator.TryValidateObject(entity, vc, vr, true)) return BadRequest(vr); // overlap (Pending/Approved) var overlapCreate = await _db.Bookings.AsNoTracking().AnyAsync(b => b.RoomId == entity.RoomId && (b.CurrentStatus == BookingStatus.Pending || b.CurrentStatus == BookingStatus.Approved) && b.StartUtc < entity.EndUtc && entity.StartUtc < b.EndUtc); if (overlapCreate) return Conflict("This time slot overlaps an existing booking for the selected room."); _db.Bookings.Add(entity); await _db.SaveChangesAsync(); return CreatedAtAction(nameof(GetAsync), new { id = entity.BookingId }, new { id = entity.BookingId }); } // ========================================================= // ========================= PUT =========================== // ========================================================= // - Update booking: PUT /api/BookingsApi?id=123 // - Update room: PUT /api/BookingsApi?scope=rooms&id=5 [HttpPut] public async Task PutAsync( [FromQuery] int? id, [FromQuery] string? scope, [FromBody] System.Text.Json.JsonElement? body ) { if (!id.HasValue || id <= 0) return BadRequest("Missing id."); var json = body?.ToString() ?? "{}"; // ROOMS: UPDATE (Managers only) if (string.Equals(scope, "rooms", StringComparison.OrdinalIgnoreCase)) { if (!IsManager()) return Unauthorized("Managers only."); var dto = JsonSerializer.Deserialize(json, JsonOpts); var r = await _db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id.Value); if (r is null) return NotFound(); if (dto is not null) { if (dto.RoomName is not null) r.RoomName = dto.RoomName.Trim(); if (dto.LocationCode is not null) r.LocationCode = string.IsNullOrWhiteSpace(dto.LocationCode) ? null : dto.LocationCode.Trim(); if (dto.Capacity.HasValue) r.Capacity = dto.Capacity.Value; if (dto.IsActive.HasValue) r.IsActive = dto.IsActive.Value; } await _db.SaveChangesAsync(); return Ok(new { roomId = r.RoomId }); } // BOOKING: UPDATE (Owner or Manager) var payload = JsonSerializer.Deserialize(json, JsonOpts); var entity = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); if (entity is null) return NotFound(); var me = GetCurrentUserId(); if (!CanModify(entity, me)) return Unauthorized("Only the creator or a manager can edit this booking."); if (entity.CurrentStatus is not BookingStatus.Pending and not BookingStatus.Rejected) return Conflict("Only Pending or Rejected bookings can be updated."); // optional: switch rooms if (payload?.RoomId is int newRoomId && newRoomId != entity.RoomId) { var roomOk2 = await _db.Rooms.AnyAsync(r => r.RoomId == newRoomId && r.IsActive); if (!roomOk2) return BadRequest("New room not found or inactive."); entity.RoomId = newRoomId; } if (payload is not null) { if (payload.Title is not null) entity.Title = payload.Title.Trim(); var purpose = (payload.Purpose ?? payload.Description)?.Trim(); if (payload.Purpose is not null || payload.Description is not null) entity.Purpose = string.IsNullOrWhiteSpace(purpose) ? null : purpose; var noteIn = (payload.Note ?? payload.Notes)?.Trim(); if (payload.Note is not null || payload.Notes is not null) entity.Note = string.IsNullOrWhiteSpace(noteIn) ? null : noteIn; // normalize + snap to 30 using incoming or existing values var newStartUtc = entity.StartUtc; var newEndUtc = entity.EndUtc; if (payload.StartUtc.HasValue) newStartUtc = SnapToNearest30(CoerceToUtc(payload.StartUtc.Value)); if (payload.EndUtc.HasValue) newEndUtc = SnapToNearest30(CoerceToUtc(payload.EndUtc.Value)); // NEW: also disallow moving an existing booking into the past var nowUtcCeil = SnapToNearest30(DateTime.UtcNow.AddSeconds(1)); if (newStartUtc < nowUtcCeil) return BadRequest("Start must be in the future."); // guards if (newEndUtc <= newStartUtc) return BadRequest("End time must be after Start time."); if (!IsSameLocalDate(newStartUtc, newEndUtc, AppTz)) return BadRequest("End date must be the same calendar date as Start."); var localStart = TimeZoneInfo.ConvertTimeFromUtc(newStartUtc, AppTz); var localEnd = TimeZoneInfo.ConvertTimeFromUtc(newEndUtc, AppTz); var windowStart = new TimeSpan(8, 0, 0); var windowEnd = new TimeSpan(20, 0, 0); if (localStart.TimeOfDay < windowStart || localEnd.TimeOfDay > windowEnd) return BadRequest("Bookings must be between 08:00 and 20:00 local time."); entity.StartUtc = newStartUtc; entity.EndUtc = newEndUtc; } entity.LastUpdatedUtc = DateTime.UtcNow; // data annotations var vc = new ValidationContext(entity); var vr = new List(); if (!Validator.TryValidateObject(entity, vc, vr, true)) return BadRequest(vr); // overlap on (possibly) new room/times var overlap = await _db.Bookings.AsNoTracking().AnyAsync(b => b.RoomId == entity.RoomId && b.BookingId != entity.BookingId && (b.CurrentStatus == BookingStatus.Pending || b.CurrentStatus == BookingStatus.Approved) && b.StartUtc < entity.EndUtc && entity.StartUtc < b.EndUtc); if (overlap) return Conflict("This time slot overlaps an existing booking for the selected room."); await _db.SaveChangesAsync(); return Ok(new { id = entity.BookingId }); } // ========================================================= // ======================= DELETE ========================== // ========================================================= // - Delete booking: DELETE /api/BookingsApi?id=123 // - Delete room: DELETE /api/BookingsApi?scope=rooms&id=5 [HttpDelete] public async Task DeleteAsync([FromQuery] int? id, [FromQuery] string? scope) { if (!id.HasValue || id <= 0) return BadRequest("Missing id."); // ROOMS: HARD DELETE (Managers only) if (string.Equals(scope, "rooms", StringComparison.OrdinalIgnoreCase)) { if (!IsManager()) return Unauthorized("Managers only."); var r = await _db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id.Value); if (r is null) return NotFound(); try { _db.Rooms.Remove(r); await _db.SaveChangesAsync(); return Ok(new { deleted = true }); } catch (DbUpdateException) { // Likely foreign key in use (existing bookings) or DB constraint return StatusCode(409, new { error = "Cannot delete: room is in use or active. Deactivate it instead." }); } catch (Exception) { return StatusCode(500, new { error = "Delete failed." }); } } // BOOKINGS: DELETE (only Pending/Rejected) + permission gate var entity = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); if (entity is null) return NotFound(); var me = GetCurrentUserId(); if (!CanModify(entity, me)) return Unauthorized("Only the creator or a manager can delete this booking."); // NEW: only Pending is editable if (entity.CurrentStatus != BookingStatus.Pending) return Conflict("Only Pending bookings can be edited."); try { _db.Bookings.Remove(entity); await _db.SaveChangesAsync(); return Ok(new { deleted = true }); } catch (DbUpdateException) { return StatusCode(409, new { error = "Cannot delete: booking is referenced or locked." }); } catch (Exception) { return StatusCode(500, new { error = "Delete failed." }); } } } }