[CLAUDE] Office: IT staff tự reassign ticket — authz Admin-OR-IT + capability endpoint (S54)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 4m17s

- BE: GetAssignableItStaffQuery {canReassign,staff} capability endpoint + AssignItTicketHandler authz Admin-OR-dept-IT (Forbidden) + assignee-must-IT (Conflict); controller /assign hạ [Authorize(Roles=Admin)]→[Authorize] (handler enforce fine-grained data-driven)
- FE: fe-admin + fe-user ItTicketsPage SHA256-identical (reverse S53 divergence), nút gate by canReassign, dropdown từ /assignable-staff (không /users)
- Test: +13 authz guard (203→216 PASS), reviewer PASS (role-string Admin chain-verified real)
- No migration (DepartmentId reuse), no menu change

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-06-08 16:12:14 +07:00
parent 18d397f095
commit ca4b60277b
13 changed files with 587 additions and 34 deletions

View File

@ -29,15 +29,20 @@ public class ItTicketsController(IMediator mediator) : ControllerBase
return NoContent();
}
// P11-D: admin re-assign ticket cho IT staff (override round-robin auto-assign).
// P11-D + S54: re-assign ticket cho IT staff. Authz Admin-OR-dept-IT enforce trong handler
// (controller [Authorize] any-auth — gotcha #44-aware: pipeline mở, handler check fine-grained).
[HttpPut("{id:guid}/assign")]
[Authorize(Roles = "Admin")]
public async Task<IActionResult> Assign(Guid id, [FromBody] AssignItTicketBody body)
{
await mediator.Send(new AssignItTicketCommand(id, body.AssignedToUserId));
return NoContent();
}
// S54: danh sách IT staff nhận ticket + cờ canReassign (FE gate nút). [Authorize] any-auth.
[HttpGet("assignable-staff")]
public async Task<IActionResult> AssignableStaff()
=> Ok(await mediator.Send(new GetAssignableItStaffQuery()));
public record UpdateItTicketStatusBody(int Status, string? Resolution);
public record AssignItTicketBody(Guid AssignedToUserId);
}

View File

@ -440,6 +440,40 @@ public class UpdateItTicketStatusHandler(IApplicationDbContext db, ICurrentUser
}
}
// S54: danh sách nhân viên tổ IT có thể nhận ticket + cờ canReassign (Admin HOẶC thành viên dept IT).
// FE gate nút "Đổi người xử lý" theo CanReassign (BE-computed, tránh silent 403 gotcha #44).
public record AssignableStaffDto(Guid Id, string FullName);
public record AssignableStaffResult(bool CanReassign, IReadOnlyList<AssignableStaffDto> Staff);
public record GetAssignableItStaffQuery : IRequest<AssignableStaffResult>;
public class GetAssignableItStaffHandler(IApplicationDbContext db, ICurrentUser cu)
: IRequestHandler<GetAssignableItStaffQuery, AssignableStaffResult>
{
public async Task<AssignableStaffResult> Handle(GetAssignableItStaffQuery q, CancellationToken ct)
{
if (cu.UserId is null) throw new UnauthorizedException();
var itDeptId = await db.Departments.AsNoTracking()
.Where(d => d.Code == "IT" && !d.IsDeleted)
.Select(d => (Guid?)d.Id)
.FirstOrDefaultAsync(ct);
var isAdmin = cu.Roles.Contains("Admin");
var myDeptId = await db.Users.AsNoTracking()
.Where(u => u.Id == cu.UserId.Value)
.Select(u => u.DepartmentId)
.FirstOrDefaultAsync(ct);
var isItStaff = itDeptId is Guid d && myDeptId == d;
var canReassign = isAdmin || isItStaff;
if (!canReassign || itDeptId is not Guid deptId)
return new AssignableStaffResult(canReassign, Array.Empty<AssignableStaffDto>());
var staff = await db.Users.AsNoTracking()
.Where(u => u.DepartmentId == deptId && u.IsActive)
.OrderBy(u => u.FullName)
.Select(u => new AssignableStaffDto(u.Id, u.FullName))
.ToListAsync(ct);
return new AssignableStaffResult(canReassign, staff);
}
}
// P11-D: admin re-assign ticket cho IT staff cụ thể (override round-robin auto-assign
// lúc Create). Denorm AssignedToFullName từ User.FullName tại thời điểm gán.
public record AssignItTicketCommand(Guid Id, Guid AssignedToUserId) : IRequest;
@ -461,9 +495,24 @@ public class AssignItTicketHandler(IApplicationDbContext db, ICurrentUser cu, ID
if (cu.UserId is null) throw new UnauthorizedException();
var t = await db.ItTickets.FirstOrDefaultAsync(x => x.Id == req.Id && !x.IsDeleted, ct);
if (t is null) throw new NotFoundException("ItTicket", req.Id);
// S54: authz — chỉ Admin HOẶC thành viên tổ IT mới reassign (controller đã hạ [Authorize] any-auth).
var itDeptId = await db.Departments.AsNoTracking()
.Where(d => d.Code == "IT" && !d.IsDeleted)
.Select(d => (Guid?)d.Id)
.FirstOrDefaultAsync(ct);
var isAdmin = cu.Roles.Contains("Admin");
var myDeptId = await db.Users.AsNoTracking()
.Where(u => u.Id == cu.UserId.Value).Select(u => u.DepartmentId).FirstOrDefaultAsync(ct);
if (!isAdmin && !(itDeptId is Guid mine && myDeptId == mine))
throw new ForbiddenException("Chỉ Admin hoặc nhân viên tổ IT mới được gán lại ticket.");
var assignee = await db.Users.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == req.AssignedToUserId && u.IsActive, ct);
if (assignee is null) throw new NotFoundException("User", req.AssignedToUserId);
// S54: assignee bắt buộc thuộc tổ IT (khớp dropdown scoped + round-robin).
if (!(itDeptId is Guid itd && assignee.DepartmentId == itd))
throw new ConflictException("Người được giao phải thuộc tổ IT.");
t.AssignedToUserId = assignee.Id;
t.AssignedToFullName = assignee.FullName;
t.UpdatedAt = clock.UtcNow;