feature: No user management endpoints (create, update, deactivate)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
/// <summary>
|
||||
/// User directory for batch assignment and operational lookups.
|
||||
/// User directory, management, and password operations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/users")]
|
||||
@@ -29,4 +30,61 @@ public class UsersController : ControllerBase
|
||||
var results = await _users.ListByRoleAsync(parsedRole);
|
||||
return Ok(ApiResponse<IReadOnlyList<UserSummaryResponse>>.Ok(results));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new user. Admin only.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize(Roles = "ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserSummaryResponse>), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
|
||||
{
|
||||
var result = await _users.CreateAsync(request);
|
||||
return StatusCode(201, ApiResponse<UserSummaryResponse>.Created(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates user details (fullName, role, isActive). Admin only.
|
||||
/// </summary>
|
||||
[HttpPatch("{id:guid}")]
|
||||
[Authorize(Roles = "ADMINISTRATOR")]
|
||||
[ProducesResponseType(typeof(ApiResponse<UserSummaryResponse>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateUserRequest request)
|
||||
{
|
||||
var result = await _users.UpdateAsync(id, request);
|
||||
return Ok(ApiResponse<UserSummaryResponse>.Ok(result));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Admin password reset for any user.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/reset-password")]
|
||||
[Authorize(Roles = "ADMINISTRATOR")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ResetPassword(Guid id, [FromBody] ResetPasswordRequest request)
|
||||
{
|
||||
await _users.ResetPasswordAsync(id, request.NewPassword);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Self-service password change. Requires current password.
|
||||
/// </summary>
|
||||
[HttpPost("me/change-password")]
|
||||
[Authorize]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
|
||||
{
|
||||
var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
await _users.ChangePasswordAsync(userId, request.CurrentPassword, request.NewPassword);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
@@ -0,0 +1,6 @@
|
||||
public record CreateUserRequest(
|
||||
string Username,
|
||||
string Password,
|
||||
string FullName,
|
||||
string Role
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
public record ResetPasswordRequest(string NewPassword);
|
||||
@@ -0,0 +1,5 @@
|
||||
public record UpdateUserRequest(
|
||||
string? FullName,
|
||||
string? Role,
|
||||
bool? IsActive
|
||||
);
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
@@ -138,7 +139,10 @@ try
|
||||
"minio",
|
||||
tags: new[] { "ready" });
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
builder.Services.AddScoped<ValidationFilter>();
|
||||
builder.Services.AddControllers(options =>
|
||||
options.Filters.AddService<ValidationFilter>());
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddVigilCareRecordsSwagger();
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
public interface IUserDirectoryService
|
||||
{
|
||||
Task<IReadOnlyList<UserSummaryResponse>> ListByRoleAsync(UserRole? role);
|
||||
Task<UserSummaryResponse> CreateAsync(CreateUserRequest request);
|
||||
Task<UserSummaryResponse> UpdateAsync(Guid userId, UpdateUserRequest request);
|
||||
Task ResetPasswordAsync(Guid userId, string newPassword);
|
||||
Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword);
|
||||
}
|
||||
|
||||
@@ -22,4 +22,74 @@ public class UserDirectoryService : IUserDirectoryService
|
||||
u.Role.ToDbString()))
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<UserSummaryResponse> CreateAsync(CreateUserRequest request)
|
||||
{
|
||||
var exists = await _db.Users.AnyAsync(u => u.Username == request.Username);
|
||||
if (exists)
|
||||
throw new ConflictException(
|
||||
$"Username '{request.Username}' is already taken.",
|
||||
"USERNAME_TAKEN");
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = request.Username,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password),
|
||||
FullName = request.FullName,
|
||||
Role = UserRoleExtensions.FromDbString(request.Role),
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
_db.Users.Add(user);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new UserSummaryResponse(user.Id, user.Username, user.FullName, user.Role.ToDbString());
|
||||
}
|
||||
|
||||
public async Task<UserSummaryResponse> UpdateAsync(Guid userId, UpdateUserRequest request)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user is null)
|
||||
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
|
||||
|
||||
if (request.FullName is not null)
|
||||
user.FullName = request.FullName;
|
||||
|
||||
if (request.Role is not null)
|
||||
user.Role = UserRoleExtensions.FromDbString(request.Role);
|
||||
|
||||
if (request.IsActive.HasValue)
|
||||
user.IsActive = request.IsActive.Value;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new UserSummaryResponse(user.Id, user.Username, user.FullName, user.Role.ToDbString());
|
||||
}
|
||||
|
||||
public async Task ResetPasswordAsync(Guid userId, string newPassword)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user is null)
|
||||
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
|
||||
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task ChangePasswordAsync(Guid userId, string currentPassword, string newPassword)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user is null)
|
||||
throw new NotFoundException("User not found.", "USER_NOT_FOUND");
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(currentPassword, user.PasswordHash))
|
||||
throw new ValidationException(
|
||||
"Current password is incorrect.",
|
||||
"INVALID_PASSWORD");
|
||||
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using FluentValidation;
|
||||
|
||||
public class LoginRequestValidator : AbstractValidator<LoginRequest>
|
||||
{
|
||||
public LoginRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Username).NotEmpty();
|
||||
RuleFor(x => x.Password).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class RefreshRequestValidator : AbstractValidator<RefreshRequest>
|
||||
{
|
||||
public RefreshRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class LogoutRequestValidator : AbstractValidator<LogoutRequest>
|
||||
{
|
||||
public LogoutRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.RefreshToken).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class UpsertDraftPatientRequestValidator : AbstractValidator<UpsertDraftPatientRequest>
|
||||
{
|
||||
public UpsertDraftPatientRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.FullName).MaximumLength(200).When(x => x.FullName is not null);
|
||||
RuleFor(x => x.DateOfBirth).LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
.WithMessage("Date of birth cannot be in the future.")
|
||||
.When(x => x.DateOfBirth.HasValue);
|
||||
RuleFor(x => x.Sex)
|
||||
.Must(s => IsAllowedSex(s))
|
||||
.WithMessage("Sex must be 'male', 'female', 'other', or 'unknown'.")
|
||||
.When(x => x.Sex is not null);
|
||||
}
|
||||
|
||||
private static bool IsAllowedSex(string? sex)
|
||||
{
|
||||
if (sex is null) return true;
|
||||
|
||||
return sex.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"m" or "male" or "f" or "female" or "other" or "unknown" => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class UpsertDraftEncounterRequestValidator : AbstractValidator<UpsertDraftEncounterRequest>
|
||||
{
|
||||
public UpsertDraftEncounterRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.AdmissionDate).LessThanOrEqualTo(DateTimeOffset.UtcNow)
|
||||
.WithMessage("Admission date cannot be in the future.")
|
||||
.When(x => x.AdmissionDate.HasValue);
|
||||
RuleFor(x => x.RoomBed).MaximumLength(50).When(x => x.RoomBed is not null);
|
||||
RuleFor(x => x.AdmissionReason).MaximumLength(500).When(x => x.AdmissionReason is not null);
|
||||
RuleFor(x => x.DischargeDiagnosis).MaximumLength(500).When(x => x.DischargeDiagnosis is not null);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateDraftObservationRequestValidator : AbstractValidator<CreateDraftObservationRequest>
|
||||
{
|
||||
public CreateDraftObservationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.ObservationCode).NotEmpty();
|
||||
RuleFor(x => x.Unit).NotEmpty();
|
||||
RuleFor(x => x.RecordedAt).NotEmpty()
|
||||
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
.WithMessage("RecordedAt cannot be in the future.");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateDraftObservationRequestValidator : AbstractValidator<UpdateDraftObservationRequest>
|
||||
{
|
||||
public UpdateDraftObservationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.ObservationCode).NotEmpty();
|
||||
RuleFor(x => x.Unit).NotEmpty();
|
||||
RuleFor(x => x.RecordedAt).NotEmpty()
|
||||
.LessThanOrEqualTo(DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
.WithMessage("RecordedAt cannot be in the future.");
|
||||
}
|
||||
}
|
||||
|
||||
public class VerifyBatchRequestValidator : AbstractValidator<VerifyBatchRequest>
|
||||
{
|
||||
private static readonly HashSet<string> _validStatuses = new() { "ok", "warning", "error" };
|
||||
|
||||
public VerifyBatchRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.FieldChecks).NotEmpty()
|
||||
.WithMessage("At least one field check is required.");
|
||||
RuleForEach(x => x.FieldChecks).ChildRules(check =>
|
||||
{
|
||||
check.RuleFor(c => c.FieldName).NotEmpty();
|
||||
check.RuleFor(c => c.Status).NotEmpty()
|
||||
.Must(s => _validStatuses.Contains(s))
|
||||
.WithMessage("Status must be 'ok', 'warning', or 'error'.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public class RejectBatchRequestValidator : AbstractValidator<RejectBatchRequest>
|
||||
{
|
||||
public RejectBatchRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Reason)
|
||||
.NotEmpty()
|
||||
.WithErrorCode("REJECTION_REASON_REQUIRED")
|
||||
.WithMessage("Rejection reason is required.");
|
||||
|
||||
RuleFor(x => x.Reason)
|
||||
.MinimumLength(10)
|
||||
.When(x => !string.IsNullOrEmpty(x.Reason))
|
||||
.WithErrorCode("REJECTION_REASON_TOO_SHORT")
|
||||
.WithMessage("Rejection reason must be at least 10 characters.");
|
||||
}
|
||||
}
|
||||
|
||||
public class RecordObservationsRequestValidator : AbstractValidator<RecordObservationsRequest>
|
||||
{
|
||||
public RecordObservationsRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Observations)
|
||||
.NotEmpty()
|
||||
.WithErrorCode("EMPTY_OBSERVATIONS")
|
||||
.WithMessage("At least one observation is required.")
|
||||
.Must(o => o.Count <= 10)
|
||||
.WithMessage("Maximum 10 observations per request.");
|
||||
RuleFor(x => x.ClinicianAttestation)
|
||||
.Equal(true)
|
||||
.WithErrorCode("ATTESTATION_REQUIRED")
|
||||
.WithMessage("Clinician attestation is required.");
|
||||
RuleFor(x => x.PasswordConfirm).NotEmpty();
|
||||
RuleForEach(x => x.Observations).SetValidator(new LiveCaptureObservationRequestValidator());
|
||||
}
|
||||
}
|
||||
|
||||
public class OpenEncounterWithVitalsRequestValidator : AbstractValidator<OpenEncounterWithVitalsRequest>
|
||||
{
|
||||
public OpenEncounterWithVitalsRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.PatientId).NotEmpty();
|
||||
RuleFor(x => x.Department).NotEmpty();
|
||||
RuleFor(x => x.AdmissionReason).NotEmpty();
|
||||
RuleFor(x => x.Observations)
|
||||
.NotEmpty()
|
||||
.WithErrorCode("EMPTY_OBSERVATIONS")
|
||||
.WithMessage("At least one observation is required.")
|
||||
.Must(o => o.Count <= 10)
|
||||
.WithMessage("Maximum 10 observations per request.");
|
||||
RuleFor(x => x.ClinicianAttestation)
|
||||
.Equal(true)
|
||||
.WithErrorCode("ATTESTATION_REQUIRED")
|
||||
.WithMessage("Clinician attestation is required.");
|
||||
RuleFor(x => x.PasswordConfirm).NotEmpty();
|
||||
RuleForEach(x => x.Observations).SetValidator(new LiveCaptureObservationRequestValidator());
|
||||
}
|
||||
}
|
||||
|
||||
public class LiveCaptureObservationRequestValidator : AbstractValidator<LiveCaptureObservationRequest>
|
||||
{
|
||||
public LiveCaptureObservationRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.ObservationCode).NotEmpty();
|
||||
RuleFor(x => x.Unit).NotEmpty();
|
||||
RuleFor(x => x.RecordedAt).NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
|
||||
{
|
||||
private static readonly HashSet<string> _validRoles = new()
|
||||
{
|
||||
"INTAKE_CLERK", "DATA_ENTRY_CLERK", "VERIFIER",
|
||||
"CLINICAL_APPROVER", "CLINICIAN", "ADMINISTRATOR"
|
||||
};
|
||||
|
||||
public CreateUserRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Username).NotEmpty().MaximumLength(50);
|
||||
RuleFor(x => x.Password).NotEmpty().MinimumLength(8)
|
||||
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.")
|
||||
.Matches("[0-9]").WithMessage("Password must contain at least one digit.");
|
||||
RuleFor(x => x.FullName).NotEmpty().MaximumLength(200);
|
||||
RuleFor(x => x.Role).NotEmpty()
|
||||
.Must(r => _validRoles.Contains(r))
|
||||
.WithMessage("Role must be one of: INTAKE_CLERK, DATA_ENTRY_CLERK, VERIFIER, CLINICAL_APPROVER, CLINICIAN, ADMINISTRATOR.");
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateUserRequestValidator : AbstractValidator<UpdateUserRequest>
|
||||
{
|
||||
private static readonly HashSet<string> _validRoles = new()
|
||||
{
|
||||
"INTAKE_CLERK", "DATA_ENTRY_CLERK", "VERIFIER",
|
||||
"CLINICAL_APPROVER", "CLINICIAN", "ADMINISTRATOR"
|
||||
};
|
||||
|
||||
public UpdateUserRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.FullName).MaximumLength(200).When(x => x.FullName is not null);
|
||||
RuleFor(x => x.Role)
|
||||
.Must(r => _validRoles.Contains(r!))
|
||||
.WithMessage("Role must be one of: INTAKE_CLERK, DATA_ENTRY_CLERK, VERIFIER, CLINICAL_APPROVER, CLINICIAN, ADMINISTRATOR.")
|
||||
.When(x => x.Role is not null);
|
||||
}
|
||||
}
|
||||
|
||||
public class ResetPasswordRequestValidator : AbstractValidator<ResetPasswordRequest>
|
||||
{
|
||||
public ResetPasswordRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8)
|
||||
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.")
|
||||
.Matches("[0-9]").WithMessage("Password must contain at least one digit.");
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangePasswordRequestValidator : AbstractValidator<ChangePasswordRequest>
|
||||
{
|
||||
public ChangePasswordRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.CurrentPassword).NotEmpty();
|
||||
RuleFor(x => x.NewPassword).NotEmpty().MinimumLength(8)
|
||||
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter.")
|
||||
.Matches("[0-9]").WithMessage("Password must contain at least one digit.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
public class ValidationFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
|
||||
public ValidationFilter(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
foreach (var argument in context.ActionArguments.Values)
|
||||
{
|
||||
if (argument is null) continue;
|
||||
|
||||
var argumentType = argument.GetType();
|
||||
var validatorType = typeof(IValidator<>).MakeGenericType(argumentType);
|
||||
var validator = _serviceProvider.GetService(validatorType) as IValidator;
|
||||
|
||||
if (validator is null) continue;
|
||||
|
||||
var validationContext = new ValidationContext<object>(argument);
|
||||
var result = await validator.ValidateAsync(validationContext);
|
||||
|
||||
if (!result.IsValid)
|
||||
{
|
||||
var errors = string.Join("; ", result.Errors.Select(e => e.ErrorMessage));
|
||||
var errorCode = result.Errors
|
||||
.Select(e => e.ErrorCode)
|
||||
.FirstOrDefault(code => !string.IsNullOrWhiteSpace(code))
|
||||
?? "VALIDATION_ERROR";
|
||||
context.Result = new UnprocessableEntityObjectResult(
|
||||
ApiResponse<object>.Fail(422, errors, errorCode));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4">
|
||||
|
||||
Reference in New Issue
Block a user