inventory_mobile/pstw_centralizesystem/Controllers/API/BookingsAPI.cs
2025-12-15 15:35:35 +08:00

877 lines
37 KiB
C#

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<BookingsAPIController> _logger;
private static readonly TimeZoneInfo AppTz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Kuala_Lumpur");
public BookingsAPIController(CentralSystemContext db, ILogger<BookingsAPIController> 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<IActionResult> 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<IActionResult> GetManagersAsync()
{
var ids = await _db.BookingManager
.AsNoTracking()
.Where(x => x.IsActive)
.Select(x => x.UserId)
.ToListAsync();
return Ok(ids);
}
public class SaveManagersDto { public List<int>? UserIds { get; set; } }
// POST /api/BookingsApi/managers body: { "userIds":[1,2,3] }
[HttpPost("managers")]
public async Task<IActionResult> 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<int>(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<IActionResult> 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<BookingStatus>(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<BookingStatus>(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<IActionResult> 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<CreateRoomDto>(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<CreateBookingDto>(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<ValidationResult>();
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<IActionResult> 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<UpdateRoomDto>(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<UpdateBookingDto>(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<ValidationResult>();
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<IActionResult> 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." });
}
}
}
}