[CLAUDE] Domain+App+Infra+Api+FE-Admin+FE-User: S36 Plan G-O2 Phòng họp Mig 36 + BE CRUD + FE 2 app
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m55s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 3m55s
Phase 10.2 G-O2 Phòng họp BookingCalendar — Mig 36 schema + BE CQRS + FE 2 app mirror cookie-cutter G-H2 HrmConfig pattern. Standalone không depend workflow. BE schema (Mig 36 — em main solo Step 4): - 4 Domain new: MeetingRoom catalog + MeetingBooking header + MeetingBookingAttendee join table N-to-N (NOT JSON per Investigator verdict) + Enums (MeetingBookingStatus 3-state: Confirmed/Cancelled/Completed) - 3 EF Config: UNIQUE Code + composite index (RoomId, StartAt) range query + UNIQUE composite (BookingId, UserId) join - FK strategy: Room→Restrict (preserve history) + Booking→Cascade attendees + User→Restrict (denorm FullName+Email tránh cascade wipe) - Mig 36 3-file rule + ApplicationDbContextModelSnapshot updated + apply Dev+Design DB BE CQRS (~584 LOC — Implementer Case 2): - MeetingFeatures.cs 479 LOC 9 handler: 4 Room CRUD + 5 Booking (List + GetById + Create + Update + Cancel) - SERIALIZABLE transaction overlap check via EXISTS query — throw 409 Conflict "Phòng đã được đặt trong khoảng thời gian này" - MeetingRoomsController 49 LOC + MeetingBookingsController 56 LOC — class-level [Authorize] + Roles="Admin" for write - Application.csproj +Microsoft.EntityFrameworkCore.Relational package (em main fix IsolationLevel overload — Implementer gotcha #53 4th truncation diagnose mid-task) - MenuKeys.cs +4 const (Off_PhongHop sub-group + View/Manage/Book leaf) - DbInitializer +SeedMeetingRoomsAsync 4 sample (PH-A Phòng họp lớn cap=20 + PH-B cap=8 + PHG-501 Giám đốc cap=6 + ONL-1 Online Zoom cap=50) — NOT gated DemoSeed per gotcha #51 INFRASTRUCTURE seed FE 2 app (~1770 LOC × 2 — Implementer Case 2): - types/meeting.ts × 2 SHA256 IDENTICAL (ce0ad9c6d017cde2) — DTO interface mirror - MeetingCalendarPage.tsx × 2 SHA256 IDENTICAL (d6d160ae1e4f2285) ~530 LOC — custom HTML 7-day grid 8h-20h slot, NO FullCalendar dep (~80 KB bundle saved per Investigator verdict alternative) - MeetingRoomsPage.tsx × 2 SHA256 IDENTICAL (ba35a7ef379a5e9c) ~270 LOC — admin catalog CRUD table + Dialog - 4-place mirror Pattern 16-bis 7× cumulative: types + page + App.tsx route + menuKeys + Layout staticMap 3 entry (gotcha #50 silent sidebar drop prevention) Verify: - dotnet build SolutionErp.slnx PASS 0 error 2 pre-existing DocxRenderer warning - dotnet test 130/130 PASS baseline preserve (58 Domain + 72 Infra) - npm build × 2 app PASS 0 TS error (fe-admin 16.91s bundle 1490 KB / fe-user 8.56s bundle 1404 KB, +23 KB gzip both) Pattern reinforced cumulative S36: - Pattern 12-bis cross-module mirror 10× (PE → Contract V2 → Hrm → Office) - Pattern 16-bis 4-place mirror cross-app 7× - Smart Friend Implementer truncation gotcha #53 4th — mitigation tight brief WORK (FE 2 app no truncation, BE truncate diagnose mid only) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -9,6 +9,7 @@ using SolutionErp.Domain.Identity;
|
||||
using SolutionErp.Domain.Master;
|
||||
using SolutionErp.Domain.Master.Catalogs;
|
||||
using SolutionErp.Domain.Notifications;
|
||||
using SolutionErp.Domain.Office;
|
||||
using SolutionErp.Domain.PurchaseEvaluations;
|
||||
|
||||
namespace SolutionErp.Application.Common.Interfaces;
|
||||
@ -103,5 +104,12 @@ public interface IApplicationDbContext
|
||||
DbSet<ShiftPattern> ShiftPatterns { get; }
|
||||
DbSet<OtPolicy> OtPolicies { get; }
|
||||
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36) — Phòng họp + Booking + Attendee join.
|
||||
// Overlap check qua SERIALIZABLE tx + EXISTS handler (clean-room SOL,
|
||||
// mirror NamGroup TblBookingResource S50 pattern). FullCalendar v6 MIT FE.
|
||||
DbSet<MeetingRoom> MeetingRooms { get; }
|
||||
DbSet<MeetingBooking> MeetingBookings { get; }
|
||||
DbSet<MeetingBookingAttendee> MeetingBookingAttendees { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
479
src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs
Normal file
479
src/Backend/SolutionErp.Application/Office/MeetingFeatures.cs
Normal file
@ -0,0 +1,479 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SolutionErp.Application.Common.Exceptions;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
using SolutionErp.Domain.Office;
|
||||
|
||||
namespace SolutionErp.Application.Office;
|
||||
|
||||
// Phase 10.2 G-O2 (Mig 36 — S36 2026-05-28) — Phòng họp + Booking CQRS.
|
||||
// Pattern 12-bis cross-module mirror Hrm/HrmConfigFeatures.cs (S35 cumulative 10×).
|
||||
// Pattern 16-bis foundation 4-place modification: entity (done) + config (done)
|
||||
// + controller (separate file) + menu/DbInit (DbInitializer SeedMenuTreeAsync extend).
|
||||
//
|
||||
// Endpoints expose qua MeetingRoomsController + MeetingBookingsController:
|
||||
// GET /api/meeting-rooms — list (filter search + active)
|
||||
// POST /api/meeting-rooms — create [Admin]
|
||||
// PUT /api/meeting-rooms/{id} — update [Admin]
|
||||
// DELETE /api/meeting-rooms/{id} — soft disable IsActive=false [Admin]
|
||||
// (NOT IsDeleted vì FK Restrict Booking)
|
||||
// GET /api/meeting-bookings — list (room + date range + my filter)
|
||||
// GET /api/meeting-bookings/{id} — detail Include Attendees
|
||||
// POST /api/meeting-bookings — create (SERIALIZABLE tx overlap check)
|
||||
// PUT /api/meeting-bookings/{id} — update (SERIALIZABLE tx exclude self)
|
||||
// DELETE /api/meeting-bookings/{id} — cancel Status=Cancelled (NOT IsDeleted — preserve history)
|
||||
//
|
||||
// Investigator verdict S36 pre-flight chốt:
|
||||
// - Overlap check qua System.Data.IsolationLevel.Serializable tx + EXISTS AnyAsync
|
||||
// - Attendees join table N-to-N (MeetingBookingAttendee), NOT JSON array
|
||||
// - 3-state Status enum (Confirmed=1 / Cancelled=2 / Completed=3)
|
||||
|
||||
// ===== DTOs =====
|
||||
|
||||
public record MeetingRoomDto(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Capacity,
|
||||
string? Location,
|
||||
string? Equipment,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record MeetingBookingAttendeeDto(
|
||||
Guid UserId,
|
||||
string FullName,
|
||||
string? Email,
|
||||
string? Notes);
|
||||
|
||||
public record MeetingBookingDto(
|
||||
Guid Id,
|
||||
Guid RoomId,
|
||||
string RoomCode,
|
||||
string RoomName,
|
||||
Guid BookedByUserId,
|
||||
string BookedByFullName,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Title,
|
||||
string? Description,
|
||||
int Status,
|
||||
string? Note,
|
||||
DateTime CreatedAt,
|
||||
List<MeetingBookingAttendeeDto> Attendees);
|
||||
|
||||
public record AttendeeInput(Guid UserId, string? Notes);
|
||||
|
||||
// ===== Region 1: MeetingRoom CRUD =====
|
||||
|
||||
public record GetMeetingRoomsQuery(string? Search = null, bool? IsActiveOnly = null) : IRequest<List<MeetingRoomDto>>;
|
||||
public class GetMeetingRoomsHandler(IApplicationDbContext db) : IRequestHandler<GetMeetingRoomsQuery, List<MeetingRoomDto>>
|
||||
{
|
||||
public async Task<List<MeetingRoomDto>> Handle(GetMeetingRoomsQuery req, CancellationToken ct)
|
||||
{
|
||||
var q = db.MeetingRooms.AsNoTracking().Where(x => !x.IsDeleted);
|
||||
if (req.IsActiveOnly == true) q = q.Where(x => x.IsActive);
|
||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||
{
|
||||
var s = req.Search.ToLower();
|
||||
q = q.Where(x =>
|
||||
x.Code.ToLower().Contains(s) ||
|
||||
x.Name.ToLower().Contains(s) ||
|
||||
(x.Location != null && x.Location.ToLower().Contains(s)));
|
||||
}
|
||||
return await q.OrderBy(x => x.Code)
|
||||
.Select(x => new MeetingRoomDto(
|
||||
x.Id, x.Code, x.Name, x.Capacity, x.Location, x.Equipment, x.IsActive, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateMeetingRoomCommand(
|
||||
string Code,
|
||||
string Name,
|
||||
int Capacity,
|
||||
string? Location,
|
||||
string? Equipment) : IRequest<Guid>;
|
||||
public class CreateMeetingRoomValidator : AbstractValidator<CreateMeetingRoomCommand>
|
||||
{
|
||||
public CreateMeetingRoomValidator()
|
||||
{
|
||||
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Capacity).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.Location).MaximumLength(200);
|
||||
RuleFor(x => x.Equipment).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
public class CreateMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<CreateMeetingRoomCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateMeetingRoomCommand req, CancellationToken ct)
|
||||
{
|
||||
if (await db.MeetingRooms.AnyAsync(x => x.Code == req.Code && !x.IsDeleted, ct))
|
||||
throw new ConflictException($"Mã phòng họp '{req.Code}' đã tồn tại.");
|
||||
var entity = new MeetingRoom
|
||||
{
|
||||
Code = req.Code,
|
||||
Name = req.Name,
|
||||
Capacity = req.Capacity,
|
||||
Location = req.Location,
|
||||
Equipment = req.Equipment,
|
||||
IsActive = true,
|
||||
};
|
||||
db.MeetingRooms.Add(entity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateMeetingRoomCommand(
|
||||
Guid Id,
|
||||
string Code,
|
||||
string Name,
|
||||
int Capacity,
|
||||
string? Location,
|
||||
string? Equipment,
|
||||
bool IsActive) : IRequest;
|
||||
public class UpdateMeetingRoomValidator : AbstractValidator<UpdateMeetingRoomCommand>
|
||||
{
|
||||
public UpdateMeetingRoomValidator()
|
||||
{
|
||||
RuleFor(x => x.Code).NotEmpty().MaximumLength(20);
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Capacity).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.Location).MaximumLength(200);
|
||||
RuleFor(x => x.Equipment).MaximumLength(500);
|
||||
}
|
||||
}
|
||||
public class UpdateMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<UpdateMeetingRoomCommand>
|
||||
{
|
||||
public async Task Handle(UpdateMeetingRoomCommand req, CancellationToken ct)
|
||||
{
|
||||
var entity = await db.MeetingRooms.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("MeetingRoom", req.Id);
|
||||
if (entity.Code != req.Code
|
||||
&& await db.MeetingRooms.AnyAsync(x => x.Code == req.Code && x.Id != req.Id && !x.IsDeleted, ct))
|
||||
throw new ConflictException($"Mã phòng họp '{req.Code}' đã tồn tại.");
|
||||
entity.Code = req.Code;
|
||||
entity.Name = req.Name;
|
||||
entity.Capacity = req.Capacity;
|
||||
entity.Location = req.Location;
|
||||
entity.Equipment = req.Equipment;
|
||||
entity.IsActive = req.IsActive;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record DeleteMeetingRoomCommand(Guid Id) : IRequest;
|
||||
public class DeleteMeetingRoomHandler(IApplicationDbContext db) : IRequestHandler<DeleteMeetingRoomCommand>
|
||||
{
|
||||
public async Task Handle(DeleteMeetingRoomCommand req, CancellationToken ct)
|
||||
{
|
||||
var entity = await db.MeetingRooms.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("MeetingRoom", req.Id);
|
||||
// FK Restrict Booking → NOT soft delete (IsDeleted=true), chỉ set IsActive=false.
|
||||
// Preserve booking history + future query filter chỉ load room đang active.
|
||||
// Admin có thể toggle IsActive=true lại nếu muốn re-enable.
|
||||
entity.IsActive = false;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Region 2: MeetingBooking CRUD =====
|
||||
// Overlap check pattern SERIALIZABLE tx + EXISTS:
|
||||
// - BeginTransactionAsync(IsolationLevel.Serializable, ct)
|
||||
// - AnyAsync(b.RoomId == cmd.RoomId && b.Status == Confirmed && !b.IsDeleted
|
||||
// && b.StartAt < cmd.EndAt && b.EndAt > cmd.StartAt)
|
||||
// - Update: exclude self (b.Id != cmd.Id)
|
||||
// SERIALIZABLE đảm bảo 2 concurrent insert overlap không cùng commit (range lock).
|
||||
|
||||
public record GetMeetingBookingsQuery(
|
||||
Guid? RoomId = null,
|
||||
DateTime? StartDate = null,
|
||||
DateTime? EndDate = null,
|
||||
bool MyOnly = false) : IRequest<List<MeetingBookingDto>>;
|
||||
|
||||
public class GetMeetingBookingsHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<GetMeetingBookingsQuery, List<MeetingBookingDto>>
|
||||
{
|
||||
public async Task<List<MeetingBookingDto>> Handle(GetMeetingBookingsQuery req, CancellationToken ct)
|
||||
{
|
||||
var q = db.MeetingBookings.AsNoTracking()
|
||||
.Include(x => x.Room)
|
||||
.Include(x => x.Attendees)
|
||||
.Where(x => !x.IsDeleted);
|
||||
|
||||
if (req.RoomId.HasValue) q = q.Where(x => x.RoomId == req.RoomId.Value);
|
||||
if (req.StartDate.HasValue) q = q.Where(x => x.EndAt >= req.StartDate.Value);
|
||||
if (req.EndDate.HasValue) q = q.Where(x => x.StartAt <= req.EndDate.Value);
|
||||
|
||||
if (req.MyOnly && currentUser.UserId is Guid uid)
|
||||
{
|
||||
// "My bookings" = đã đặt OR được mời (Attendee).
|
||||
q = q.Where(x => x.BookedByUserId == uid || x.Attendees.Any(a => a.UserId == uid));
|
||||
}
|
||||
|
||||
return await q.OrderBy(x => x.StartAt)
|
||||
.Select(x => new MeetingBookingDto(
|
||||
x.Id,
|
||||
x.RoomId,
|
||||
x.Room.Code,
|
||||
x.Room.Name,
|
||||
x.BookedByUserId,
|
||||
x.BookedByFullName,
|
||||
x.StartAt,
|
||||
x.EndAt,
|
||||
x.Title,
|
||||
x.Description,
|
||||
(int)x.Status,
|
||||
x.Note,
|
||||
x.CreatedAt,
|
||||
x.Attendees
|
||||
.Select(a => new MeetingBookingAttendeeDto(a.UserId, a.FullName, a.Email, a.Notes))
|
||||
.ToList()))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record GetMeetingBookingByIdQuery(Guid Id) : IRequest<MeetingBookingDto?>;
|
||||
public class GetMeetingBookingByIdHandler(IApplicationDbContext db)
|
||||
: IRequestHandler<GetMeetingBookingByIdQuery, MeetingBookingDto?>
|
||||
{
|
||||
public async Task<MeetingBookingDto?> Handle(GetMeetingBookingByIdQuery req, CancellationToken ct)
|
||||
{
|
||||
return await db.MeetingBookings.AsNoTracking()
|
||||
.Include(x => x.Room)
|
||||
.Include(x => x.Attendees)
|
||||
.Where(x => x.Id == req.Id && !x.IsDeleted)
|
||||
.Select(x => new MeetingBookingDto(
|
||||
x.Id,
|
||||
x.RoomId,
|
||||
x.Room.Code,
|
||||
x.Room.Name,
|
||||
x.BookedByUserId,
|
||||
x.BookedByFullName,
|
||||
x.StartAt,
|
||||
x.EndAt,
|
||||
x.Title,
|
||||
x.Description,
|
||||
(int)x.Status,
|
||||
x.Note,
|
||||
x.CreatedAt,
|
||||
x.Attendees
|
||||
.Select(a => new MeetingBookingAttendeeDto(a.UserId, a.FullName, a.Email, a.Notes))
|
||||
.ToList()))
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateMeetingBookingCommand(
|
||||
Guid RoomId,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Title,
|
||||
string? Description,
|
||||
string? Note,
|
||||
List<AttendeeInput> Attendees) : IRequest<Guid>;
|
||||
|
||||
public class CreateMeetingBookingValidator : AbstractValidator<CreateMeetingBookingCommand>
|
||||
{
|
||||
public CreateMeetingBookingValidator()
|
||||
{
|
||||
RuleFor(x => x.RoomId).NotEmpty();
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Description).MaximumLength(1000);
|
||||
RuleFor(x => x.Note).MaximumLength(500);
|
||||
RuleFor(x => x.Attendees).NotEmpty().WithMessage("Cần ít nhất 1 người tham gia.");
|
||||
RuleFor(x => x).Must(c => c.EndAt > c.StartAt)
|
||||
.WithMessage("Giờ kết thúc phải sau giờ bắt đầu.");
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<CreateMeetingBookingCommand, Guid>
|
||||
{
|
||||
public async Task<Guid> Handle(CreateMeetingBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not Guid userId)
|
||||
throw new UnauthorizedException();
|
||||
|
||||
// Verify Room exists + IsActive (NotFoundException nếu fail)
|
||||
var roomActive = await db.MeetingRooms
|
||||
.AnyAsync(r => r.Id == req.RoomId && r.IsActive && !r.IsDeleted, ct);
|
||||
if (!roomActive)
|
||||
throw new NotFoundException("MeetingRoom", req.RoomId);
|
||||
|
||||
// SERIALIZABLE transaction — range lock prevent 2 concurrent insert overlap commit.
|
||||
// EXISTS query overlap detect: tồn tại booking Confirmed cùng Room khoảng giao thời.
|
||||
await using var tx = await ((Microsoft.EntityFrameworkCore.DbContext)db)
|
||||
.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
|
||||
|
||||
var overlap = await db.MeetingBookings.AnyAsync(b =>
|
||||
b.RoomId == req.RoomId &&
|
||||
b.Status == MeetingBookingStatus.Confirmed &&
|
||||
!b.IsDeleted &&
|
||||
b.StartAt < req.EndAt && b.EndAt > req.StartAt,
|
||||
ct);
|
||||
if (overlap)
|
||||
throw new ConflictException("Phòng đã được đặt trong khoảng thời gian này.");
|
||||
|
||||
// Resolve attendee FullName + Email từ Users JOIN (denorm).
|
||||
var attendeeIds = req.Attendees.Select(a => a.UserId).Distinct().ToList();
|
||||
var attendeeUsers = await db.Users.AsNoTracking()
|
||||
.Where(u => attendeeIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToListAsync(ct);
|
||||
var attendeeLookup = attendeeUsers.ToDictionary(u => u.Id);
|
||||
|
||||
var entity = new MeetingBooking
|
||||
{
|
||||
RoomId = req.RoomId,
|
||||
BookedByUserId = userId,
|
||||
BookedByFullName = currentUser.FullName ?? string.Empty,
|
||||
StartAt = req.StartAt,
|
||||
EndAt = req.EndAt,
|
||||
Title = req.Title,
|
||||
Description = req.Description,
|
||||
Note = req.Note,
|
||||
Status = MeetingBookingStatus.Confirmed,
|
||||
Attendees = req.Attendees
|
||||
.Where(a => attendeeLookup.ContainsKey(a.UserId)) // skip user không tồn tại
|
||||
.Select(a => new MeetingBookingAttendee
|
||||
{
|
||||
UserId = a.UserId,
|
||||
FullName = attendeeLookup[a.UserId].FullName,
|
||||
Email = attendeeLookup[a.UserId].Email,
|
||||
Notes = a.Notes,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
db.MeetingBookings.Add(entity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateMeetingBookingCommand(
|
||||
Guid Id,
|
||||
Guid RoomId,
|
||||
DateTime StartAt,
|
||||
DateTime EndAt,
|
||||
string Title,
|
||||
string? Description,
|
||||
string? Note,
|
||||
List<AttendeeInput> Attendees) : IRequest;
|
||||
|
||||
public class UpdateMeetingBookingValidator : AbstractValidator<UpdateMeetingBookingCommand>
|
||||
{
|
||||
public UpdateMeetingBookingValidator()
|
||||
{
|
||||
RuleFor(x => x.RoomId).NotEmpty();
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Description).MaximumLength(1000);
|
||||
RuleFor(x => x.Note).MaximumLength(500);
|
||||
RuleFor(x => x.Attendees).NotEmpty().WithMessage("Cần ít nhất 1 người tham gia.");
|
||||
RuleFor(x => x).Must(c => c.EndAt > c.StartAt)
|
||||
.WithMessage("Giờ kết thúc phải sau giờ bắt đầu.");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<UpdateMeetingBookingCommand>
|
||||
{
|
||||
public async Task Handle(UpdateMeetingBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not Guid userId)
|
||||
throw new UnauthorizedException();
|
||||
|
||||
var entity = await db.MeetingBookings
|
||||
.Include(x => x.Attendees)
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("MeetingBooking", req.Id);
|
||||
|
||||
// Authorization: owner OR Admin role
|
||||
var isOwner = entity.BookedByUserId == userId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép sửa booking.");
|
||||
|
||||
// Verify Room exists + IsActive
|
||||
var roomActive = await db.MeetingRooms
|
||||
.AnyAsync(r => r.Id == req.RoomId && r.IsActive && !r.IsDeleted, ct);
|
||||
if (!roomActive)
|
||||
throw new NotFoundException("MeetingRoom", req.RoomId);
|
||||
|
||||
// SERIALIZABLE transaction — overlap check exclude SELF (b.Id != req.Id).
|
||||
await using var tx = await ((Microsoft.EntityFrameworkCore.DbContext)db)
|
||||
.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
|
||||
|
||||
var overlap = await db.MeetingBookings.AnyAsync(b =>
|
||||
b.Id != req.Id &&
|
||||
b.RoomId == req.RoomId &&
|
||||
b.Status == MeetingBookingStatus.Confirmed &&
|
||||
!b.IsDeleted &&
|
||||
b.StartAt < req.EndAt && b.EndAt > req.StartAt,
|
||||
ct);
|
||||
if (overlap)
|
||||
throw new ConflictException("Phòng đã được đặt trong khoảng thời gian này.");
|
||||
|
||||
// Update header fields
|
||||
entity.RoomId = req.RoomId;
|
||||
entity.StartAt = req.StartAt;
|
||||
entity.EndAt = req.EndAt;
|
||||
entity.Title = req.Title;
|
||||
entity.Description = req.Description;
|
||||
entity.Note = req.Note;
|
||||
// Status preserve (Cancel via separate command).
|
||||
|
||||
// Recreate Attendees: delete existing + re-add (idempotent simpler than diff).
|
||||
var attendeeIds = req.Attendees.Select(a => a.UserId).Distinct().ToList();
|
||||
var attendeeUsers = await db.Users.AsNoTracking()
|
||||
.Where(u => attendeeIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FullName, u.Email })
|
||||
.ToListAsync(ct);
|
||||
var attendeeLookup = attendeeUsers.ToDictionary(u => u.Id);
|
||||
|
||||
// Remove old (Cascade FK on Booking.Attendees handles cleanup via collection)
|
||||
db.MeetingBookingAttendees.RemoveRange(entity.Attendees);
|
||||
entity.Attendees = req.Attendees
|
||||
.Where(a => attendeeLookup.ContainsKey(a.UserId))
|
||||
.Select(a => new MeetingBookingAttendee
|
||||
{
|
||||
BookingId = entity.Id,
|
||||
UserId = a.UserId,
|
||||
FullName = attendeeLookup[a.UserId].FullName,
|
||||
Email = attendeeLookup[a.UserId].Email,
|
||||
Notes = a.Notes,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
await tx.CommitAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record CancelMeetingBookingCommand(Guid Id) : IRequest;
|
||||
public class CancelMeetingBookingHandler(IApplicationDbContext db, ICurrentUser currentUser)
|
||||
: IRequestHandler<CancelMeetingBookingCommand>
|
||||
{
|
||||
public async Task Handle(CancelMeetingBookingCommand req, CancellationToken ct)
|
||||
{
|
||||
if (currentUser.UserId is not Guid userId)
|
||||
throw new UnauthorizedException();
|
||||
|
||||
var entity = await db.MeetingBookings
|
||||
.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct)
|
||||
?? throw new NotFoundException("MeetingBooking", req.Id);
|
||||
|
||||
var isOwner = entity.BookedByUserId == userId;
|
||||
var isAdmin = currentUser.Roles.Contains("Admin");
|
||||
if (!isOwner && !isAdmin)
|
||||
throw new ForbiddenException("Chỉ người đặt phòng hoặc Admin được phép huỷ booking.");
|
||||
|
||||
// Status=Cancelled (NOT IsDeleted=true) — preserve history + audit trail.
|
||||
entity.Status = MeetingBookingStatus.Cancelled;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,8 @@
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.6" />
|
||||
<!-- Relational cần cho IsolationLevel overload BeginTransactionAsync (S36 G-O2 SERIALIZABLE overlap) -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user