877 lines
37 KiB
C#
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." });
|
|
}
|
|
}
|
|
}
|
|
}
|