Add facilities, contracts, orders, value lists, audit log, and desktop app modules
Extends omsorgCore with full CRUD for Facility/Contract/Order plus configurable value lists and an audit trail, and wires the omsorgapp frontend up to the new facilities, settings, and audit-log modules; includes a sidebar active-nav-item highlight. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ee74ed65f5
commit
e9e96a57dc
@@ -0,0 +1,5 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AddUserPermissionOverrideRequest(ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AuditLogEntryResponse(
|
||||
Guid Id,
|
||||
DateTime OccurredAtUtc,
|
||||
Guid? ActorUserId,
|
||||
string? ActorUsername,
|
||||
string? IpAddress,
|
||||
string Category,
|
||||
string Action,
|
||||
string? EntityType,
|
||||
Guid? EntityId,
|
||||
string? Details);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ContractResponse(
|
||||
Guid Id,
|
||||
string ContractType,
|
||||
Guid? EmployeeId,
|
||||
Guid? FacilityId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string Status,
|
||||
decimal? WeeklyHours,
|
||||
decimal? HourlyWage,
|
||||
string? AllowancesDescription,
|
||||
string? OvertimeRules,
|
||||
int? VacationDaysPerYear,
|
||||
int? ProbationPeriodMonths);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateContractRequest(
|
||||
string ContractType,
|
||||
Guid? EmployeeId,
|
||||
Guid? FacilityId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
decimal? WeeklyHours,
|
||||
decimal? HourlyWage,
|
||||
string? AllowancesDescription,
|
||||
string? OvertimeRules,
|
||||
int? VacationDaysPerYear,
|
||||
int? ProbationPeriodMonths);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateFacilityContactRequest(
|
||||
string Name,
|
||||
string? Role,
|
||||
string? Department,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
string? Notes);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateFacilityRequest(
|
||||
string Name,
|
||||
string? FacilityType,
|
||||
string? Website,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateOrderRequest(
|
||||
Guid FacilityId,
|
||||
Guid? FacilityContactId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? RequiredQualification,
|
||||
string? ShiftType,
|
||||
int RequiredHeadcount,
|
||||
string? Conditions,
|
||||
string Priority);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateValueListItemRequest(
|
||||
string Value,
|
||||
int SortOrder,
|
||||
bool IsDefault = false,
|
||||
bool IsInitial = false,
|
||||
bool IsTerminal = false);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record FacilityContactResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string Name,
|
||||
string? Role,
|
||||
string? Department,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
string? Notes);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record FacilityResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string CrmStatus,
|
||||
string? FacilityType,
|
||||
string? Website,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record OrderResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
Guid? FacilityContactId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? RequiredQualification,
|
||||
string? ShiftType,
|
||||
int RequiredHeadcount,
|
||||
string? Conditions,
|
||||
string Priority,
|
||||
Guid StatusId,
|
||||
string StatusName);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PasswordPolicyResponse(int MinLength);
|
||||
@@ -1,3 +1,5 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PermissionDto(string Module, string Action);
|
||||
public record PermissionDto(ModuleType Module, PermissionAction Action);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record RolePermissionsResponse(Guid Id, string Name, IReadOnlyList<PermissionDto> Permissions);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateContractRequest(
|
||||
string ContractType,
|
||||
Guid? EmployeeId,
|
||||
Guid? FacilityId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string Status,
|
||||
decimal? WeeklyHours,
|
||||
decimal? HourlyWage,
|
||||
string? AllowancesDescription,
|
||||
string? OvertimeRules,
|
||||
int? VacationDaysPerYear,
|
||||
int? ProbationPeriodMonths);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateFacilityContactRequest(
|
||||
string Name,
|
||||
string? Role,
|
||||
string? Department,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
string? Notes);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateFacilityRequest(
|
||||
string Name,
|
||||
string CrmStatus,
|
||||
string? FacilityType,
|
||||
string? Website,
|
||||
string? Street,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
string? Country,
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateOrderRequest(
|
||||
Guid FacilityId,
|
||||
Guid? FacilityContactId,
|
||||
DateOnly StartDate,
|
||||
DateOnly? EndDate,
|
||||
string? RequiredQualification,
|
||||
string? ShiftType,
|
||||
int RequiredHeadcount,
|
||||
string? Conditions,
|
||||
string Priority,
|
||||
Guid StatusId);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateRolePermissionsRequest(IReadOnlyList<PermissionDto> Permissions);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateValueListItemRequest(
|
||||
string Value,
|
||||
int SortOrder,
|
||||
bool IsDefault = false,
|
||||
bool IsInitial = false,
|
||||
bool IsTerminal = false);
|
||||
@@ -0,0 +1,5 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UserPermissionOverrideResponse(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListItemResponse(
|
||||
Guid Id,
|
||||
string Value,
|
||||
int SortOrder,
|
||||
bool IsDefault,
|
||||
bool IsInitial,
|
||||
bool IsTerminal);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListResponse(Guid Id, string Key, string DisplayName);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListTransitionRequest(Guid FromItemId, Guid ToItemId);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListTransitionResponse(Guid Id, Guid FromItemId, Guid ToItemId);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListUsageResponse(string EntityType, Guid EntityId, string DisplayLabel);
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
@@ -17,10 +20,17 @@ namespace OmsorgCore.Api.Controllers;
|
||||
public class AdminSessionsController : ControllerBase
|
||||
{
|
||||
private readonly ISessionAdminService _sessionAdminService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public AdminSessionsController(ISessionAdminService sessionAdminService)
|
||||
public AdminSessionsController(
|
||||
ISessionAdminService sessionAdminService,
|
||||
ICurrentUserService currentUserService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_sessionAdminService = sessionAdminService;
|
||||
_currentUserService = currentUserService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -36,6 +46,14 @@ public class AdminSessionsController : ControllerBase
|
||||
public async Task<IActionResult> Revoke(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
await _sessionAdminService.RevokeSessionAsync(id, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(
|
||||
_currentUserService.UserId,
|
||||
_currentUserService.Username,
|
||||
_currentUserService.IpAddress,
|
||||
"SessionRevoked",
|
||||
JsonSerializer.Serialize(new { sessionId = id })),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -44,6 +62,13 @@ public class AdminSessionsController : ControllerBase
|
||||
public async Task<IActionResult> RevokeAll(CancellationToken cancellationToken)
|
||||
{
|
||||
await _sessionAdminService.RevokeAllSessionsAsync(cancellationToken);
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(
|
||||
_currentUserService.UserId,
|
||||
_currentUserService.Username,
|
||||
_currentUserService.IpAddress,
|
||||
"AllSessionsRevoked"),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Nachvollziehbarkeit "wer hat wann was am System verändert" - kombiniert automatisch erfasste
|
||||
/// Entity-Änderungen (AuditSaveChangesInterceptor) und explizit gemeldete Verhaltens-Ereignisse
|
||||
/// (AuditEvent, z. B. Login/Logout/Session-Kill). Per Default nur für die Rolle Geschäftsführung
|
||||
/// sichtbar (siehe DbSeeder.SeedBaseRolesAsync).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/audit-log")]
|
||||
public class AuditLogController : ControllerBase
|
||||
{
|
||||
private readonly IAuditLogService _auditLogService;
|
||||
|
||||
public AuditLogController(IAuditLogService auditLogService)
|
||||
{
|
||||
_auditLogService = auditLogService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.AuditLog, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<AuditLogEntryResponse>>> GetAll(
|
||||
[FromQuery] string? entityType,
|
||||
[FromQuery] Guid? entityId,
|
||||
[FromQuery] Guid? actorUserId,
|
||||
[FromQuery] AuditEventCategory? category,
|
||||
[FromQuery] DateTime? fromUtc,
|
||||
[FromQuery] DateTime? toUtc,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 200);
|
||||
|
||||
var (items, totalCount) = await _auditLogService.GetPagedAsync(
|
||||
entityType, entityId, actorUserId, category, fromUtc, toUtc, page, pageSize, cancellationToken);
|
||||
|
||||
return Ok(new PagedResponse<AuditLogEntryResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
private static AuditLogEntryResponse ToResponse(AuditLogEntry entry) => new(
|
||||
entry.Id,
|
||||
entry.OccurredAtUtc,
|
||||
entry.ActorUserId,
|
||||
entry.ActorUsername,
|
||||
entry.IpAddress,
|
||||
entry.Category.ToString(),
|
||||
entry.Action,
|
||||
entry.EntityType,
|
||||
entry.EntityId,
|
||||
entry.Details);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Engine.Events;
|
||||
using OmsorgCore.Infrastructure.Security;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
@@ -16,19 +18,22 @@ public class AuthController : ControllerBase
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
private readonly PasswordPolicyOptions _passwordPolicyOptions;
|
||||
|
||||
public AuthController(
|
||||
IAuthService authService,
|
||||
ICurrentUserService currentUserService,
|
||||
IPasswordResetService passwordResetService,
|
||||
IUserService userService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
IDomainEventDispatcher dispatcher,
|
||||
IOptions<PasswordPolicyOptions> passwordPolicyOptions)
|
||||
{
|
||||
_authService = authService;
|
||||
_currentUserService = currentUserService;
|
||||
_passwordResetService = passwordResetService;
|
||||
_userService = userService;
|
||||
_dispatcher = dispatcher;
|
||||
_passwordPolicyOptions = passwordPolicyOptions.Value;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
@@ -44,9 +49,11 @@ public class AuthController : ControllerBase
|
||||
|
||||
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
|
||||
{
|
||||
await _dispatcher.DispatchAsync(new AuditEvent(null, request.Username, ipAddress, "LoginFailed"), cancellationToken);
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
await _dispatcher.DispatchAsync(new AuditEvent(result.UserId, result.Username, ipAddress, "Login"), cancellationToken);
|
||||
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
@@ -66,6 +73,9 @@ public class AuthController : ControllerBase
|
||||
public async Task<IActionResult> Logout(LogoutRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _authService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(_currentUserService.UserId, _currentUserService.Username, _currentUserService.IpAddress, "Logout"),
|
||||
cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -85,7 +95,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
var permissions = profile.Permissions
|
||||
.Select(p => new PermissionDto(p.Module.ToString(), p.Action.ToString()))
|
||||
.Select(p => new PermissionDto(p.Module, p.Action))
|
||||
.ToList();
|
||||
|
||||
return Ok(new MeResponse(
|
||||
@@ -110,13 +120,19 @@ public class AuthController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
|
||||
var result = await _userService.ChangeOwnPasswordAsync(userId, request.CurrentPassword, request.NewPassword, cancellationToken);
|
||||
return result switch
|
||||
{
|
||||
return BadRequest("NewPassword muss mindestens 8 Zeichen lang sein.");
|
||||
}
|
||||
ChangeOwnPasswordResult.Success => NoContent(),
|
||||
ChangeOwnPasswordResult.PasswordTooShort => BadRequest($"NewPassword muss mindestens {_passwordPolicyOptions.MinLength} Zeichen lang sein."),
|
||||
_ => Unauthorized()
|
||||
};
|
||||
}
|
||||
|
||||
var success = await _userService.ChangeOwnPasswordAsync(userId, request.CurrentPassword, request.NewPassword, cancellationToken);
|
||||
return success ? NoContent() : Unauthorized();
|
||||
[HttpGet("password-policy")]
|
||||
public ActionResult<PasswordPolicyResponse> PasswordPolicy()
|
||||
{
|
||||
return Ok(new PasswordPolicyResponse(_passwordPolicyOptions.MinLength));
|
||||
}
|
||||
|
||||
[HttpPost("forgot-password/request")]
|
||||
@@ -156,8 +172,13 @@ public class AuthController : ControllerBase
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var success = await _passwordResetService.ResetPasswordAsync(
|
||||
var result = await _passwordResetService.ResetPasswordAsync(
|
||||
request.ResetToken, request.NewPassword, cancellationToken);
|
||||
return success ? NoContent() : Unauthorized();
|
||||
return result switch
|
||||
{
|
||||
ResetPasswordResult.Success => NoContent(),
|
||||
ResetPasswordResult.PasswordTooShort => BadRequest($"NewPassword muss mindestens {_passwordPolicyOptions.MinLength} Zeichen lang sein."),
|
||||
_ => Unauthorized()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/contracts")]
|
||||
public class ContractsController : ControllerBase
|
||||
{
|
||||
private const string ContractTypeListKey = "ContractType";
|
||||
private const string ContractStatusListKey = "ContractStatus";
|
||||
|
||||
private readonly IContractService _contractService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public ContractsController(IContractService contractService, IValueListRepository valueListRepository, IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_contractService = contractService;
|
||||
_valueListRepository = valueListRepository;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<ContractResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] Guid? employeeId,
|
||||
[FromQuery] Guid? facilityId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _contractService.GetPagedAsync(search, status, employeeId, facilityId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<ContractResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.View)]
|
||||
public async Task<ActionResult<ContractResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var contract = await _contractService.GetByIdAsync(id, cancellationToken);
|
||||
return contract is null ? NotFound() : Ok(ToResponse(contract));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Create)]
|
||||
public async Task<ActionResult<ContractResponse>> Create(CreateContractRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = await ValidateFieldsAsync(
|
||||
request.ContractType,
|
||||
request.EmployeeId,
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.WeeklyHours,
|
||||
request.HourlyWage,
|
||||
request.AllowancesDescription,
|
||||
request.OvertimeRules,
|
||||
request.VacationDaysPerYear,
|
||||
request.ProbationPeriodMonths,
|
||||
cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var contract = new Contract
|
||||
{
|
||||
ContractType = request.ContractType,
|
||||
EmployeeId = request.EmployeeId,
|
||||
FacilityId = request.FacilityId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
HourlyWage = request.HourlyWage,
|
||||
AllowancesDescription = request.AllowancesDescription,
|
||||
OvertimeRules = request.OvertimeRules,
|
||||
VacationDaysPerYear = request.VacationDaysPerYear,
|
||||
ProbationPeriodMonths = request.ProbationPeriodMonths
|
||||
};
|
||||
|
||||
var created = await _contractService.CreateAsync(contract, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(new ContractCreatedEvent(created.Id), cancellationToken);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ContractResponse>> Update(Guid id, UpdateContractRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = await ValidateFieldsAsync(
|
||||
request.ContractType,
|
||||
request.EmployeeId,
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.WeeklyHours,
|
||||
request.HourlyWage,
|
||||
request.AllowancesDescription,
|
||||
request.OvertimeRules,
|
||||
request.VacationDaysPerYear,
|
||||
request.ProbationPeriodMonths,
|
||||
cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Status) || request.Status.Length > 50)
|
||||
{
|
||||
return BadRequest("Status ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var allowedStatuses = await _valueListRepository.GetActiveValuesAsync(ContractStatusListKey, cancellationToken);
|
||||
if (!allowedStatuses.Contains(request.Status))
|
||||
{
|
||||
return BadRequest($"Status muss einer der folgenden Werte sein: {string.Join(", ", allowedStatuses)}.");
|
||||
}
|
||||
|
||||
var updates = new Contract
|
||||
{
|
||||
ContractType = request.ContractType,
|
||||
EmployeeId = request.EmployeeId,
|
||||
FacilityId = request.FacilityId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Status = request.Status,
|
||||
WeeklyHours = request.WeeklyHours,
|
||||
HourlyWage = request.HourlyWage,
|
||||
AllowancesDescription = request.AllowancesDescription,
|
||||
OvertimeRules = request.OvertimeRules,
|
||||
VacationDaysPerYear = request.VacationDaysPerYear,
|
||||
ProbationPeriodMonths = request.ProbationPeriodMonths
|
||||
};
|
||||
|
||||
var updated = await _contractService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFieldsAsync(
|
||||
string contractType,
|
||||
Guid? employeeId,
|
||||
Guid? facilityId,
|
||||
DateOnly startDate,
|
||||
DateOnly? endDate,
|
||||
decimal? weeklyHours,
|
||||
decimal? hourlyWage,
|
||||
string? allowancesDescription,
|
||||
string? overtimeRules,
|
||||
int? vacationDaysPerYear,
|
||||
int? probationPeriodMonths,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contractType) || contractType.Length > 100)
|
||||
{
|
||||
return "ContractType ist erforderlich und darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
var allowedContractTypes = await _valueListRepository.GetActiveValuesAsync(ContractTypeListKey, cancellationToken);
|
||||
if (!allowedContractTypes.Contains(contractType))
|
||||
{
|
||||
return $"ContractType muss einer der folgenden Werte sein: {string.Join(", ", allowedContractTypes)}.";
|
||||
}
|
||||
|
||||
if (employeeId is null && facilityId is null)
|
||||
{
|
||||
return "Ein Vertrag muss entweder einem Mitarbeiter oder einer Einrichtung zugeordnet sein.";
|
||||
}
|
||||
|
||||
if (startDate == default)
|
||||
{
|
||||
return "StartDate ist erforderlich.";
|
||||
}
|
||||
|
||||
if (endDate.HasValue && endDate.Value < startDate)
|
||||
{
|
||||
return "EndDate darf nicht vor StartDate liegen.";
|
||||
}
|
||||
|
||||
if (weeklyHours.HasValue && weeklyHours.Value < 0)
|
||||
{
|
||||
return "WeeklyHours darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
if (hourlyWage.HasValue && hourlyWage.Value < 0)
|
||||
{
|
||||
return "HourlyWage darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
if (vacationDaysPerYear.HasValue && vacationDaysPerYear.Value < 0)
|
||||
{
|
||||
return "VacationDaysPerYear darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
if (probationPeriodMonths.HasValue && probationPeriodMonths.Value < 0)
|
||||
{
|
||||
return "ProbationPeriodMonths darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
if (allowancesDescription is { Length: > 500 })
|
||||
{
|
||||
return "AllowancesDescription darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (overtimeRules is { Length: > 500 })
|
||||
{
|
||||
return "OvertimeRules darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ContractResponse ToResponse(Contract contract)
|
||||
=> new(
|
||||
contract.Id,
|
||||
contract.ContractType,
|
||||
contract.EmployeeId,
|
||||
contract.FacilityId,
|
||||
contract.StartDate,
|
||||
contract.EndDate,
|
||||
contract.Status,
|
||||
contract.WeeklyHours,
|
||||
contract.HourlyWage,
|
||||
contract.AllowancesDescription,
|
||||
contract.OvertimeRules,
|
||||
contract.VacationDaysPerYear,
|
||||
contract.ProbationPeriodMonths);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
@@ -14,15 +15,17 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/employees")]
|
||||
public class EmployeesController : ControllerBase
|
||||
{
|
||||
private static readonly string[] AllowedEmploymentTypes =
|
||||
{ "Vollzeit", "Teilzeit", "Minijob", "Aushilfe", "Praktikant", "Freiberuflich" };
|
||||
private const string StatusListKey = "EmployeeStatus";
|
||||
private const string EmploymentTypeListKey = "EmploymentType";
|
||||
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public EmployeesController(IEmployeeService employeeService, IDomainEventDispatcher dispatcher)
|
||||
public EmployeesController(IEmployeeService employeeService, IValueListRepository valueListRepository, IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_employeeService = employeeService;
|
||||
_valueListRepository = valueListRepository;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
@@ -36,9 +39,13 @@ public class EmployeesController : ControllerBase
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
|
||||
if (employmentType is not null)
|
||||
{
|
||||
return BadRequest($"employmentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.");
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(EmploymentTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(employmentType))
|
||||
{
|
||||
return BadRequest($"employmentType muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.");
|
||||
}
|
||||
}
|
||||
|
||||
page = Math.Max(page, 1);
|
||||
@@ -91,12 +98,13 @@ public class EmployeesController : ControllerBase
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var stammdatenError = ValidateStammdatenFields(
|
||||
var stammdatenError = await ValidateStammdatenFieldsAsync(
|
||||
request.EmergencyContactName,
|
||||
request.EmergencyContactPhone,
|
||||
request.EmergencyContactRelation,
|
||||
request.EmploymentType,
|
||||
request.Qualification);
|
||||
request.Qualification,
|
||||
cancellationToken);
|
||||
if (stammdatenError is not null)
|
||||
{
|
||||
return BadRequest(stammdatenError);
|
||||
@@ -147,6 +155,12 @@ public class EmployeesController : ControllerBase
|
||||
return BadRequest("Status ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var allowedStatuses = await _valueListRepository.GetActiveValuesAsync(StatusListKey, cancellationToken);
|
||||
if (!allowedStatuses.Contains(request.Status))
|
||||
{
|
||||
return BadRequest($"Status muss einer der folgenden Werte sein: {string.Join(", ", allowedStatuses)}.");
|
||||
}
|
||||
|
||||
if (request.PhoneNumber is { Length: > 50 })
|
||||
{
|
||||
return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein.");
|
||||
@@ -168,12 +182,13 @@ public class EmployeesController : ControllerBase
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var stammdatenError = ValidateStammdatenFields(
|
||||
var stammdatenError = await ValidateStammdatenFieldsAsync(
|
||||
request.EmergencyContactName,
|
||||
request.EmergencyContactPhone,
|
||||
request.EmergencyContactRelation,
|
||||
request.EmploymentType,
|
||||
request.Qualification);
|
||||
request.Qualification,
|
||||
cancellationToken);
|
||||
if (stammdatenError is not null)
|
||||
{
|
||||
return BadRequest(stammdatenError);
|
||||
@@ -230,12 +245,13 @@ public class EmployeesController : ControllerBase
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateStammdatenFields(
|
||||
private async Task<string?> ValidateStammdatenFieldsAsync(
|
||||
string? emergencyContactName,
|
||||
string? emergencyContactPhone,
|
||||
string? emergencyContactRelation,
|
||||
string? employmentType,
|
||||
string? qualification)
|
||||
string? qualification,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (emergencyContactName is { Length: > 200 })
|
||||
{
|
||||
@@ -252,9 +268,13 @@ public class EmployeesController : ControllerBase
|
||||
return "EmergencyContactRelation darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType))
|
||||
if (employmentType is not null)
|
||||
{
|
||||
return $"EmploymentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}.";
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(EmploymentTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(employmentType))
|
||||
{
|
||||
return $"EmploymentType muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.";
|
||||
}
|
||||
}
|
||||
|
||||
if (qualification is { Length: > 500 })
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/facilities")]
|
||||
public class FacilitiesController : ControllerBase
|
||||
{
|
||||
private const string CrmStatusListKey = "CrmStatus";
|
||||
private const string FacilityTypeListKey = "FacilityType";
|
||||
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public FacilitiesController(IFacilityService facilityService, IValueListRepository valueListRepository, IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_facilityService = facilityService;
|
||||
_valueListRepository = valueListRepository;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<FacilityResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? crmStatus,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _facilityService.GetPagedAsync(search, crmStatus, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<FacilityResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.View)]
|
||||
public async Task<ActionResult<FacilityResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var facility = await _facilityService.GetByIdAsync(id, cancellationToken);
|
||||
return facility is null ? NotFound() : Ok(ToResponse(facility));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Create)]
|
||||
public async Task<ActionResult<FacilityResponse>> Create(CreateFacilityRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 300)
|
||||
{
|
||||
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is { Length: > 100 })
|
||||
{
|
||||
return BadRequest("FacilityType darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is not null)
|
||||
{
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(FacilityTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(request.FacilityType))
|
||||
{
|
||||
return BadRequest($"FacilityType muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Website is { Length: > 300 })
|
||||
{
|
||||
return BadRequest("Website darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country, prefix: "");
|
||||
if (addressError is not null)
|
||||
{
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var billingAddressError = ValidateAddressFields(request.BillingStreet, request.BillingPostalCode, request.BillingCity, request.BillingCountry, prefix: "Billing");
|
||||
if (billingAddressError is not null)
|
||||
{
|
||||
return BadRequest(billingAddressError);
|
||||
}
|
||||
|
||||
var facility = new Facility
|
||||
{
|
||||
Name = request.Name,
|
||||
FacilityType = request.FacilityType,
|
||||
Website = request.Website,
|
||||
Street = request.Street,
|
||||
PostalCode = request.PostalCode,
|
||||
City = request.City,
|
||||
Country = request.Country,
|
||||
BillingStreet = request.BillingStreet,
|
||||
BillingPostalCode = request.BillingPostalCode,
|
||||
BillingCity = request.BillingCity,
|
||||
BillingCountry = request.BillingCountry
|
||||
};
|
||||
|
||||
var created = await _facilityService.CreateAsync(facility, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(new FacilityCreatedEvent(created.Id), cancellationToken);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<FacilityResponse>> Update(Guid id, UpdateFacilityRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 300)
|
||||
{
|
||||
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.CrmStatus) || request.CrmStatus.Length > 50)
|
||||
{
|
||||
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var allowedCrmStatuses = await _valueListRepository.GetActiveValuesAsync(CrmStatusListKey, cancellationToken);
|
||||
if (!allowedCrmStatuses.Contains(request.CrmStatus))
|
||||
{
|
||||
return BadRequest($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", allowedCrmStatuses)}.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is { Length: > 100 })
|
||||
{
|
||||
return BadRequest("FacilityType darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.FacilityType is not null)
|
||||
{
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(FacilityTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(request.FacilityType))
|
||||
{
|
||||
return BadRequest($"FacilityType muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.Website is { Length: > 300 })
|
||||
{
|
||||
return BadRequest("Website darf maximal 300 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country, prefix: "");
|
||||
if (addressError is not null)
|
||||
{
|
||||
return BadRequest(addressError);
|
||||
}
|
||||
|
||||
var billingAddressError = ValidateAddressFields(request.BillingStreet, request.BillingPostalCode, request.BillingCity, request.BillingCountry, prefix: "Billing");
|
||||
if (billingAddressError is not null)
|
||||
{
|
||||
return BadRequest(billingAddressError);
|
||||
}
|
||||
|
||||
var updates = new Facility
|
||||
{
|
||||
Name = request.Name,
|
||||
CrmStatus = request.CrmStatus,
|
||||
FacilityType = request.FacilityType,
|
||||
Website = request.Website,
|
||||
Street = request.Street,
|
||||
PostalCode = request.PostalCode,
|
||||
City = request.City,
|
||||
Country = request.Country,
|
||||
BillingStreet = request.BillingStreet,
|
||||
BillingPostalCode = request.BillingPostalCode,
|
||||
BillingCity = request.BillingCity,
|
||||
BillingCountry = request.BillingCountry
|
||||
};
|
||||
|
||||
var updated = await _facilityService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country, string prefix)
|
||||
{
|
||||
if (street is { Length: > 200 })
|
||||
{
|
||||
return $"{prefix}Street darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (postalCode is { Length: > 10 })
|
||||
{
|
||||
return $"{prefix}PostalCode darf maximal 10 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (city is { Length: > 100 })
|
||||
{
|
||||
return $"{prefix}City darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (country is { Length: > 100 })
|
||||
{
|
||||
return $"{prefix}Country darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static FacilityResponse ToResponse(Facility facility)
|
||||
=> new(
|
||||
facility.Id,
|
||||
facility.Name,
|
||||
facility.CrmStatus,
|
||||
facility.FacilityType,
|
||||
facility.Website,
|
||||
facility.Street,
|
||||
facility.PostalCode,
|
||||
facility.City,
|
||||
facility.Country,
|
||||
facility.BillingStreet,
|
||||
facility.BillingPostalCode,
|
||||
facility.BillingCity,
|
||||
facility.BillingCountry);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Ansprechpartner sind eine 1:n-Unterressource von Facility (REQUIREMENTS.md FR-EIN-2) — kein
|
||||
/// eigenständiges Core-Objekt, daher unter /api/facilities/{facilityId}/contacts und mit den
|
||||
/// gleichen Facilities-Rechten gegated statt einem eigenen ModuleType.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/facilities/{facilityId:guid}/contacts")]
|
||||
public class FacilityContactsController : ControllerBase
|
||||
{
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IFacilityContactService _facilityContactService;
|
||||
|
||||
public FacilityContactsController(IFacilityService facilityService, IFacilityContactService facilityContactService)
|
||||
{
|
||||
_facilityService = facilityService;
|
||||
_facilityContactService = facilityContactService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<FacilityContactResponse>>> GetAll(Guid facilityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var contacts = await _facilityContactService.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
return Ok(contacts.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Create)]
|
||||
public async Task<ActionResult<FacilityContactResponse>> Create(Guid facilityId, CreateFacilityContactRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var fieldError = ValidateRequest(request.Name, request.Role, request.Department, request.PhoneNumber, request.Email, request.Notes);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var contact = new FacilityContact
|
||||
{
|
||||
FacilityId = facilityId,
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Department = request.Department,
|
||||
PhoneNumber = request.PhoneNumber,
|
||||
Email = request.Email,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
var created = await _facilityContactService.CreateAsync(contact, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetAll), new { facilityId }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<FacilityContactResponse>> Update(Guid facilityId, Guid id, UpdateFacilityContactRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityContactService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null || existing.FacilityId != facilityId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var fieldError = ValidateRequest(request.Name, request.Role, request.Department, request.PhoneNumber, request.Email, request.Notes);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var updates = new FacilityContact
|
||||
{
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Department = request.Department,
|
||||
PhoneNumber = request.PhoneNumber,
|
||||
Email = request.Email,
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
var updated = await _facilityContactService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
private static string? ValidateRequest(string name, string? role, string? department, string? phoneNumber, string? email, string? notes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Length > 200)
|
||||
{
|
||||
return "Name ist erforderlich und darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (role is { Length: > 100 })
|
||||
{
|
||||
return "Role darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (department is { Length: > 100 })
|
||||
{
|
||||
return "Department darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (phoneNumber is { Length: > 50 })
|
||||
{
|
||||
return "PhoneNumber darf maximal 50 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (email is { Length: > 200 })
|
||||
{
|
||||
return "Email darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (notes is { Length: > 1000 })
|
||||
{
|
||||
return "Notes darf maximal 1000 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static FacilityContactResponse ToResponse(FacilityContact contact)
|
||||
=> new(
|
||||
contact.Id,
|
||||
contact.FacilityId,
|
||||
contact.Name,
|
||||
contact.Role,
|
||||
contact.Department,
|
||||
contact.PhoneNumber,
|
||||
contact.Email,
|
||||
contact.Notes);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OmsorgCore.Infrastructure.Persistence;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
@@ -8,26 +7,17 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/health")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private readonly OmsorgCoreDbContext _db;
|
||||
private readonly IDatabaseConnectivityChecker _databaseConnectivityChecker;
|
||||
|
||||
public HealthController(OmsorgCoreDbContext db)
|
||||
public HealthController(IDatabaseConnectivityChecker databaseConnectivityChecker)
|
||||
{
|
||||
_db = db;
|
||||
_databaseConnectivityChecker = databaseConnectivityChecker;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
bool databaseReachable;
|
||||
try
|
||||
{
|
||||
databaseReachable = await _db.Database.CanConnectAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
databaseReachable = false;
|
||||
}
|
||||
|
||||
var databaseReachable = await _databaseConnectivityChecker.CanConnectAsync(cancellationToken);
|
||||
return Ok(new { status = "ok", databaseReachable });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/orders")]
|
||||
public class OrdersController : ControllerBase
|
||||
{
|
||||
private const string StatusListKey = "OrderStatus";
|
||||
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IFacilityContactService _facilityContactService;
|
||||
private readonly IValueListService _valueListService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public OrdersController(
|
||||
IOrderService orderService,
|
||||
IFacilityContactService facilityContactService,
|
||||
IValueListService valueListService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_orderService = orderService;
|
||||
_facilityContactService = facilityContactService;
|
||||
_valueListService = valueListService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<OrderResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] Guid? statusId,
|
||||
[FromQuery] Guid? facilityId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _orderService.GetPagedAsync(search, statusId, facilityId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<OrderResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.View)]
|
||||
public async Task<ActionResult<OrderResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var order = await _orderService.GetByIdAsync(id, cancellationToken);
|
||||
return order is null ? NotFound() : Ok(ToResponse(order));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Create)]
|
||||
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.RequiredQualification,
|
||||
request.ShiftType,
|
||||
request.RequiredHeadcount,
|
||||
request.Conditions,
|
||||
request.Priority);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var contactError = await ValidateFacilityContactAsync(request.FacilityContactId, request.FacilityId, cancellationToken);
|
||||
if (contactError is not null)
|
||||
{
|
||||
return BadRequest(contactError);
|
||||
}
|
||||
|
||||
var order = new Order
|
||||
{
|
||||
FacilityId = request.FacilityId,
|
||||
FacilityContactId = request.FacilityContactId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
RequiredQualification = request.RequiredQualification,
|
||||
ShiftType = request.ShiftType,
|
||||
RequiredHeadcount = request.RequiredHeadcount,
|
||||
Conditions = request.Conditions,
|
||||
Priority = request.Priority
|
||||
};
|
||||
|
||||
var created = await _orderService.CreateAsync(order, cancellationToken);
|
||||
await _dispatcher.DispatchAsync(new OrderCreatedEvent(created.Id), cancellationToken);
|
||||
|
||||
var reloaded = await _orderService.GetByIdAsync(created.Id, cancellationToken) ?? created;
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(reloaded));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<OrderResponse>> Update(Guid id, UpdateOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
request.RequiredQualification,
|
||||
request.ShiftType,
|
||||
request.RequiredHeadcount,
|
||||
request.Conditions,
|
||||
request.Priority);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var contactError = await ValidateFacilityContactAsync(request.FacilityContactId, request.FacilityId, cancellationToken);
|
||||
if (contactError is not null)
|
||||
{
|
||||
return BadRequest(contactError);
|
||||
}
|
||||
|
||||
var statusItems = await _valueListService.GetItemsAsync(StatusListKey, cancellationToken);
|
||||
if (!statusItems.Any(s => s.Id == request.StatusId))
|
||||
{
|
||||
return BadRequest("StatusId verweist auf keinen bekannten Auftragsstatus.");
|
||||
}
|
||||
|
||||
var updates = new Order
|
||||
{
|
||||
FacilityId = request.FacilityId,
|
||||
FacilityContactId = request.FacilityContactId,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
RequiredQualification = request.RequiredQualification,
|
||||
ShiftType = request.ShiftType,
|
||||
RequiredHeadcount = request.RequiredHeadcount,
|
||||
Conditions = request.Conditions,
|
||||
Priority = request.Priority,
|
||||
StatusId = request.StatusId
|
||||
};
|
||||
|
||||
var result = await _orderService.UpdateAsync(id, updates, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
UpdateOrderFailureReason.OrderNotFound => NotFound(),
|
||||
UpdateOrderFailureReason.InvalidStatusTransition => BadRequest("Der Statuswechsel ist nicht zulässig."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
return Ok(ToResponse(result.Order!));
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFacilityContactAsync(Guid? facilityContactId, Guid facilityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (facilityContactId is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var contact = await _facilityContactService.GetByIdAsync(facilityContactId.Value, cancellationToken);
|
||||
if (contact is null || contact.FacilityId != facilityId)
|
||||
{
|
||||
return "Der Ansprechpartner gehört nicht zur angegebenen Einrichtung.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateFields(
|
||||
Guid facilityId,
|
||||
DateOnly startDate,
|
||||
DateOnly? endDate,
|
||||
string? requiredQualification,
|
||||
string? shiftType,
|
||||
int requiredHeadcount,
|
||||
string? conditions,
|
||||
string priority)
|
||||
{
|
||||
if (facilityId == Guid.Empty)
|
||||
{
|
||||
return "FacilityId ist erforderlich.";
|
||||
}
|
||||
|
||||
if (startDate == default)
|
||||
{
|
||||
return "StartDate ist erforderlich.";
|
||||
}
|
||||
|
||||
if (endDate.HasValue && endDate.Value < startDate)
|
||||
{
|
||||
return "EndDate darf nicht vor StartDate liegen.";
|
||||
}
|
||||
|
||||
if (requiredHeadcount < 1)
|
||||
{
|
||||
return "RequiredHeadcount muss mindestens 1 sein.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(priority) || priority.Length > 50)
|
||||
{
|
||||
return "Priority ist erforderlich und darf maximal 50 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (requiredQualification is { Length: > 200 })
|
||||
{
|
||||
return "RequiredQualification darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (shiftType is { Length: > 100 })
|
||||
{
|
||||
return "ShiftType darf maximal 100 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (conditions is { Length: > 500 })
|
||||
{
|
||||
return "Conditions darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static OrderResponse ToResponse(Order order)
|
||||
=> new(
|
||||
order.Id,
|
||||
order.FacilityId,
|
||||
order.FacilityContactId,
|
||||
order.StartDate,
|
||||
order.EndDate,
|
||||
order.RequiredQualification,
|
||||
order.ShiftType,
|
||||
order.RequiredHeadcount,
|
||||
order.Conditions,
|
||||
order.Priority,
|
||||
order.StatusId,
|
||||
order.Status?.Value ?? string.Empty);
|
||||
}
|
||||
@@ -48,4 +48,40 @@ public class RolesController : ControllerBase
|
||||
|
||||
return CreatedAtAction(nameof(GetAll), new RoleResponse(result.Role!.Id, result.Role.Name));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
public async Task<ActionResult<RolePermissionsResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var role = await _roleService.GetByIdWithPermissionsAsync(id, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
return NotFound("Rolle nicht gefunden.");
|
||||
}
|
||||
|
||||
var permissions = role.RolePermissions
|
||||
.Select(rp => new PermissionDto(rp.Module, rp.Action))
|
||||
.ToList();
|
||||
|
||||
return Ok(new RolePermissionsResponse(role.Id, role.Name, permissions));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/permissions")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> UpdatePermissions(Guid id, UpdateRolePermissionsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var parsed = request.Permissions.Select(dto => (dto.Module, dto.Action)).ToList();
|
||||
|
||||
var result = await _roleService.UpdatePermissionsAsync(id, parsed, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
UpdateRolePermissionsFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
using OmsorgCore.Engine.Events;
|
||||
using OmsorgCore.Infrastructure.Security;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
@@ -14,18 +16,19 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/users")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private const int MinInitialPasswordLength = 8;
|
||||
private const int DefaultPinValidityDays = 7;
|
||||
private const int MinPinValidityDays = 1;
|
||||
private const int MaxPinValidityDays = 30;
|
||||
|
||||
private readonly IUserService _userService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
private readonly PasswordPolicyOptions _passwordPolicyOptions;
|
||||
|
||||
public UsersController(IUserService userService, IDomainEventDispatcher dispatcher)
|
||||
public UsersController(IUserService userService, IDomainEventDispatcher dispatcher, IOptions<PasswordPolicyOptions> passwordPolicyOptions)
|
||||
{
|
||||
_userService = userService;
|
||||
_dispatcher = dispatcher;
|
||||
_passwordPolicyOptions = passwordPolicyOptions.Value;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -65,6 +68,7 @@ public class UsersController : ControllerBase
|
||||
CreateUserFailureReason.UsernameTaken => Conflict("Dieser Username ist bereits vergeben."),
|
||||
CreateUserFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
|
||||
CreateUserFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
|
||||
CreateUserFailureReason.PasswordTooShort => BadRequest($"InitialPassword muss mindestens {_passwordPolicyOptions.MinLength} Zeichen lang sein."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
@@ -100,6 +104,7 @@ public class UsersController : ControllerBase
|
||||
AdminResetPasswordFailureReason.UserNotFound => NotFound("User nicht gefunden."),
|
||||
AdminResetPasswordFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."),
|
||||
AdminResetPasswordFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."),
|
||||
AdminResetPasswordFailureReason.PasswordTooShort => BadRequest($"InitialPassword muss mindestens {_passwordPolicyOptions.MinLength} Zeichen lang sein."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
@@ -153,15 +158,65 @@ public class UsersController : ControllerBase
|
||||
pinValidity = TimeSpan.FromDays(days);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct
|
||||
&& (initialPassword is null || initialPassword.Length < MinInitialPasswordLength))
|
||||
if (mode == UserCreationMode.Direct && initialPassword is null)
|
||||
{
|
||||
return ($"InitialPassword ist im Direct-Modus erforderlich und muss mindestens {MinInitialPasswordLength} Zeichen lang sein.", mode, null);
|
||||
return ("InitialPassword ist im Direct-Modus erforderlich.", mode, null);
|
||||
}
|
||||
|
||||
return (null, mode, pinValidity);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/permission-overrides")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<UserPermissionOverrideResponse>>> GetPermissionOverrides(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var overrides = await _userService.GetPermissionOverridesAsync(id, cancellationToken);
|
||||
if (overrides is null)
|
||||
{
|
||||
return NotFound("User nicht gefunden.");
|
||||
}
|
||||
|
||||
return Ok(overrides.Select(o => new UserPermissionOverrideResponse(
|
||||
o.Id, o.Module, o.Action, o.Effect)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/permission-overrides")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<UserPermissionOverrideResponse>> AddPermissionOverride(
|
||||
Guid id, AddUserPermissionOverrideRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userService.AddPermissionOverrideAsync(id, request.Module, request.Action, request.Effect, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
AddPermissionOverrideFailureReason.UserNotFound => NotFound("User nicht gefunden."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
var o = result.Override!;
|
||||
return Ok(new UserPermissionOverrideResponse(o.Id, o.Module, o.Action, o.Effect));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/permission-overrides/{overrideId:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> RemovePermissionOverride(Guid id, Guid overrideId, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userService.RemovePermissionOverrideAsync(id, overrideId, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
RemovePermissionOverrideFailureReason.UserNotFound => NotFound("User nicht gefunden."),
|
||||
RemovePermissionOverrideFailureReason.OverrideNotFound => NotFound("Override nicht gefunden."),
|
||||
_ => BadRequest()
|
||||
};
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static UserResponse ToResponse(UserSummary summary)
|
||||
=> new(summary.Id, summary.Username, summary.EmployeeId, summary.RoleName, summary.IsActive, summary.MustChangePassword);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using OmsorgCore.Api.Contracts;
|
||||
using OmsorgCore.Api.Security;
|
||||
using OmsorgCore.Application.Services;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet die konfigurierbaren Auswahllisten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status,
|
||||
/// Einrichtungstyp, Vertragstyp/-status, Auftragsstatus) — siehe omsorgCore/CLAUDE.md, Abschnitt
|
||||
/// "Konfigurierbare Auswahllisten". Lesen ist für jeden eingeloggten Nutzer erlaubt (die aufrufenden
|
||||
/// Formulare gehören zu unterschiedlichen Modulen), Schreiben ist eine Admin-Funktion und läuft über
|
||||
/// dasselbe Recht wie die übrige "Einstellungen"-Seite (<see cref="ModuleType.UserManagement"/>).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/value-lists")]
|
||||
public class ValueListsController : ControllerBase
|
||||
{
|
||||
private readonly IValueListService _valueListService;
|
||||
|
||||
public ValueListsController(IValueListService valueListService)
|
||||
{
|
||||
_valueListService = valueListService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListResponse>>> GetLists(CancellationToken cancellationToken)
|
||||
{
|
||||
var lists = await _valueListService.GetListsAsync(cancellationToken);
|
||||
return Ok(lists.Select(l => new ValueListResponse(l.Id, l.Key, l.DisplayName)).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{key}/items")]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListItemResponse>>> GetItems(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var items = await _valueListService.GetItemsAsync(key, cancellationToken);
|
||||
return Ok(items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{key}/items")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ValueListItemResponse>> CreateItem(string key, CreateValueListItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||
{
|
||||
return BadRequest("Value ist erforderlich und darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var item = await _valueListService.CreateItemAsync(
|
||||
key, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, cancellationToken);
|
||||
return Ok(ToResponse(item));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{key}/items/{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ValueListItemResponse>> UpdateItem(string key, Guid id, UpdateValueListItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||
{
|
||||
return BadRequest("Value ist erforderlich und darf maximal 100 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var item = await _valueListService.UpdateItemAsync(
|
||||
id, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, cancellationToken);
|
||||
return item is null ? NotFound() : Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
[HttpDelete("{key}/items/{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> DeleteItem(string key, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _valueListService.DeleteItemAsync(id, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
return result.FailureReason switch
|
||||
{
|
||||
DeleteValueListItemFailureReason.ItemNotFound => NotFound(),
|
||||
DeleteValueListItemFailureReason.InUse => Conflict(result.Usages
|
||||
.Select(u => new ValueListUsageResponse(u.EntityType, u.EntityId, u.DisplayLabel))
|
||||
.ToList()),
|
||||
_ => Conflict()
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{key}/items/{id:guid}/usages")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListUsageResponse>>> GetUsages(string key, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var usages = await _valueListService.GetUsagesAsync(id, cancellationToken);
|
||||
return Ok(usages.Select(u => new ValueListUsageResponse(u.EntityType, u.EntityId, u.DisplayLabel)).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{key}/transitions")]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListTransitionResponse>>> GetTransitions(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var transitions = await _valueListService.GetTransitionsAsync(key, cancellationToken);
|
||||
return Ok(transitions.Select(t => new ValueListTransitionResponse(t.Id, t.FromItemId, t.ToItemId)).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("{key}/transitions")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> ReplaceTransitions(string key, List<ValueListTransitionRequest> request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _valueListService.ReplaceTransitionsAsync(key, request.Select(t => (t.FromItemId, t.ToItemId)), cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static ValueListItemResponse ToResponse(ValueListItem item)
|
||||
=> new(item.Id, item.Value, item.SortOrder, item.IsDefault, item.IsInitial, item.IsTerminal);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -17,7 +18,8 @@ using OmsorgCore.Infrastructure.Security;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
@@ -132,6 +134,34 @@ using (var migrationScope = app.Services.CreateScope())
|
||||
await db.Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Basis-Rollen (Geschäftsführung/Disposition/Recruiting/Außendienst, siehe REQUIREMENTS.md Abschnitt 3/7)
|
||||
// in jeder Umgebung anlegen - reine Rollen-Stammdaten ohne Zugangsdaten, anders als der
|
||||
// Development-only-Admin-Seed unten. Nicht fatal bei Fehlern, analog zum Admin-Seed.
|
||||
try
|
||||
{
|
||||
using var baseRolesScope = app.Services.CreateScope();
|
||||
var db = baseRolesScope.ServiceProvider.GetRequiredService<OmsorgCoreDbContext>();
|
||||
await DbSeeder.SeedBaseRolesAsync(db);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Seed der Basis-Rollen fehlgeschlagen (z.B. Datenbank nicht erreichbar).");
|
||||
}
|
||||
|
||||
// Konfigurierbare Auswahllisten (Mitarbeiterstatus, Beschäftigungsart, CRM-Status, Einrichtungstyp,
|
||||
// Vertragstyp/-status, Auftragsstatus inkl. Übergangsregeln, FR-EM-2) - reine Stammdaten, jede
|
||||
// Umgebung, nicht fatal.
|
||||
try
|
||||
{
|
||||
using var valueListScope = app.Services.CreateScope();
|
||||
var db = valueListScope.ServiceProvider.GetRequiredService<OmsorgCoreDbContext>();
|
||||
await DbSeeder.SeedValueListsAsync(db);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogWarning(ex, "Seed der Auswahllisten fehlgeschlagen (z.B. Datenbank nicht erreichbar).");
|
||||
}
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
|
||||
@@ -24,4 +24,10 @@ public class CurrentUserService : ICurrentUserService
|
||||
return Guid.TryParse(value, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
|
||||
public string? Username => _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Name);
|
||||
|
||||
public string? RoleName => _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Role);
|
||||
|
||||
public string? IpAddress => _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"RefreshToken": {
|
||||
"ExpiryDays": 60
|
||||
},
|
||||
"PasswordPolicy": {
|
||||
"MinLength": 8
|
||||
},
|
||||
"Auth": {
|
||||
"MaxLoginFailures": 5,
|
||||
"LoginLockoutMinutes": 10
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IAuditLogRepository
|
||||
{
|
||||
Task AddAsync(AuditLogEntry entry, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(IReadOnlyList<AuditLogEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
string? entityType,
|
||||
Guid? entityId,
|
||||
Guid? actorUserId,
|
||||
AuditEventCategory? category,
|
||||
DateTime? fromUtc,
|
||||
DateTime? toUtc,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IAuditLogService
|
||||
{
|
||||
Task<(IReadOnlyList<AuditLogEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
string? entityType,
|
||||
Guid? entityId,
|
||||
Guid? actorUserId,
|
||||
AuditEventCategory? category,
|
||||
DateTime? fromUtc,
|
||||
DateTime? toUtc,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IContractRepository
|
||||
{
|
||||
Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
Guid? employeeId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -9,4 +9,7 @@ public interface ICurrentUserService
|
||||
{
|
||||
Guid? UserId { get; }
|
||||
bool IsAuthenticated { get; }
|
||||
string? Username { get; }
|
||||
string? RoleName { get; }
|
||||
string? IpAddress { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob die Datenbank erreichbar ist, ohne dass Api-Controller dafür direkt an
|
||||
/// EF Core/DbContext koppeln müssen (siehe Health-Check).
|
||||
/// </summary>
|
||||
public interface IDatabaseConnectivityChecker
|
||||
{
|
||||
Task<bool> CanConnectAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IFacilityContactRepository
|
||||
{
|
||||
Task<FacilityContact?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IFacilityRepository
|
||||
{
|
||||
Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Facility>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IOrderRepository
|
||||
{
|
||||
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Order> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Einzige Stelle, die entscheidet, ob ein Passwort die konfigurierte Mindestlänge erfüllt -
|
||||
/// wird von jedem Application-Service injiziert, der ein Passwort entgegennimmt (UserService,
|
||||
/// PasswordResetService), damit die Regel nicht mehrfach/inkonsistent dupliziert wird.
|
||||
/// </summary>
|
||||
public interface IPasswordPolicy
|
||||
{
|
||||
int MinLength { get; }
|
||||
|
||||
bool IsValid(string? password);
|
||||
}
|
||||
@@ -6,6 +6,16 @@ public interface IRoleRepository
|
||||
{
|
||||
Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<Role?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Inklusive RolePermissions - für das Lesen/Bearbeiten der Rechte-Matrix einer Rolle.</summary>
|
||||
Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Explizit über das DbSet statt über role.RolePermissions.Add(...) - sonst behandelt EF Core neue
|
||||
/// Kind-Entitäten mit bereits client-seitig gesetzter Guid-Id fälschlich als "existiert schon" und
|
||||
/// generiert ein UPDATE statt INSERT (0 betroffene Zeilen, DbUpdateConcurrencyException).
|
||||
/// </summary>
|
||||
Task AddPermissionRangeAsync(IEnumerable<RolePermission> permissions, CancellationToken cancellationToken = default);
|
||||
Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Role role, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -23,5 +23,12 @@ public interface IUserRepository
|
||||
|
||||
Task AddAsync(User user, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Explizit über das DbSet statt über user.PermissionOverrides.Add(...) - sonst behandelt EF Core
|
||||
/// die neue Kind-Entität mit bereits client-seitig gesetzter Guid-Id fälschlich als "existiert schon"
|
||||
/// und generiert ein UPDATE statt INSERT (0 betroffene Zeilen, DbUpdateConcurrencyException).
|
||||
/// </summary>
|
||||
Task AddPermissionOverrideAsync(UserPermissionOverride @override, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IValueListRepository
|
||||
{
|
||||
Task<IReadOnlyList<ValueList>> GetAllListsAsync(CancellationToken cancellationToken = default);
|
||||
Task<ValueList?> GetListByKeyAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ValueListItem>> GetItemsAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<string>> GetActiveValuesAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> GetItemByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> GetInitialItemAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddItemAsync(ValueListItem item, CancellationToken cancellationToken = default);
|
||||
Task UpdateItemAsync(ValueListItem item, CancellationToken cancellationToken = default);
|
||||
Task RemoveItemAsync(ValueListItem item, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> CanTransitionAsync(Guid fromItemId, Guid toItemId, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ValueListItemTransition>> GetTransitionsAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task ReplaceTransitionsAsync(string key, IEnumerable<(Guid FromItemId, Guid ToItemId)> transitions, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public record ValueListUsageEntry(string EntityType, Guid EntityId, string DisplayLabel);
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein bestimmter <c>ValueListItem</c>-Wert (identifiziert über den <c>Key</c> seiner
|
||||
/// Liste) noch irgendwo verwendet wird — Voraussetzung dafür, dass <c>ValueListService.DeleteItemAsync</c>
|
||||
/// das Löschen verweigert, solange Treffer existieren (Nutzeranforderung: "erst überall entfernen").
|
||||
/// Eine Implementierung je Liste, registriert in Infrastructure/DependencyInjection.cs.
|
||||
/// </summary>
|
||||
public interface IValueListUsageChecker
|
||||
{
|
||||
string Key { get; }
|
||||
|
||||
Task<IReadOnlyList<ValueListUsageEntry>> FindUsagesAsync(Guid itemId, string itemValue, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -11,10 +11,16 @@ public static class DependencyInjection
|
||||
services.AddScoped<IPermissionService, PermissionService>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IEmployeeService, EmployeeService>();
|
||||
services.AddScoped<IFacilityService, FacilityService>();
|
||||
services.AddScoped<IFacilityContactService, FacilityContactService>();
|
||||
services.AddScoped<IContractService, ContractService>();
|
||||
services.AddScoped<IOrderService, OrderService>();
|
||||
services.AddScoped<IValueListService, ValueListService>();
|
||||
services.AddScoped<ISessionAdminService, SessionAdminService>();
|
||||
services.AddScoped<IPasswordResetService, PasswordResetService>();
|
||||
services.AddScoped<IUserService, UserService>();
|
||||
services.AddScoped<IRoleService, RoleService>();
|
||||
services.AddScoped<IAuditLogService, AuditLogService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Models;
|
||||
|
||||
/// <summary>Ein einzelner UserPermissionOverride-Eintrag für die Admin-Ansicht/-Bearbeitung.</summary>
|
||||
public record PermissionOverrideSummary(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
@@ -0,0 +1,27 @@
|
||||
using OmsorgCore.Application.Models;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum AddPermissionOverrideFailureReason
|
||||
{
|
||||
UserNotFound
|
||||
}
|
||||
|
||||
public class AddPermissionOverrideResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public AddPermissionOverrideFailureReason? FailureReason { get; init; }
|
||||
public PermissionOverrideSummary? Override { get; init; }
|
||||
|
||||
public static AddPermissionOverrideResult Fail(AddPermissionOverrideFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static AddPermissionOverrideResult Ok(PermissionOverrideSummary @override) => new()
|
||||
{
|
||||
Success = true,
|
||||
Override = @override
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,8 @@ public enum AdminResetPasswordFailureReason
|
||||
{
|
||||
UserNotFound,
|
||||
EmployeeEmailMissing,
|
||||
InitialPasswordRequired
|
||||
InitialPasswordRequired,
|
||||
PasswordTooShort
|
||||
}
|
||||
|
||||
public class AdminResetPasswordResult
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class AuditLogService : IAuditLogService
|
||||
{
|
||||
private readonly IAuditLogRepository _auditLogRepository;
|
||||
|
||||
public AuditLogService(IAuditLogRepository auditLogRepository)
|
||||
{
|
||||
_auditLogRepository = auditLogRepository;
|
||||
}
|
||||
|
||||
public Task<(IReadOnlyList<AuditLogEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
string? entityType,
|
||||
Guid? entityId,
|
||||
Guid? actorUserId,
|
||||
AuditEventCategory? category,
|
||||
DateTime? fromUtc,
|
||||
DateTime? toUtc,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _auditLogRepository.GetPagedAsync(entityType, entityId, actorUserId, category, fromUtc, toUtc, page, pageSize, cancellationToken);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ namespace OmsorgCore.Application.Services;
|
||||
public class AuthResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public Guid? UserId { get; init; }
|
||||
public string? Username { get; init; }
|
||||
public string? Token { get; init; }
|
||||
public string? RefreshToken { get; init; }
|
||||
public DateTime? ExpiresAt { get; init; }
|
||||
@@ -13,9 +15,11 @@ public class AuthResult
|
||||
|
||||
public static AuthResult LockedOut() => new() { Success = false, IsLockedOut = true };
|
||||
|
||||
public static AuthResult Ok(string token, string refreshToken, DateTime expiresAt, bool mustChangePassword) => new()
|
||||
public static AuthResult Ok(Guid userId, string username, string token, string refreshToken, DateTime expiresAt, bool mustChangePassword) => new()
|
||||
{
|
||||
Success = true,
|
||||
UserId = userId,
|
||||
Username = username,
|
||||
Token = token,
|
||||
RefreshToken = refreshToken,
|
||||
ExpiresAt = expiresAt,
|
||||
|
||||
@@ -150,6 +150,6 @@ public class AuthService : IAuthService
|
||||
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return AuthResult.Ok(accessToken, rawRefreshToken, accessTokenExpiresAt, user.MustChangePassword);
|
||||
return AuthResult.Ok(user.Id, user.Username, accessToken, rawRefreshToken, accessTokenExpiresAt, user.MustChangePassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum ChangeOwnPasswordResult
|
||||
{
|
||||
Success,
|
||||
InvalidCurrentPassword,
|
||||
PasswordTooShort
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class ContractService : IContractService
|
||||
{
|
||||
private readonly IContractRepository _contractRepository;
|
||||
|
||||
public ContractService(IContractRepository contractRepository)
|
||||
{
|
||||
_contractRepository = contractRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
Guid? employeeId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetPagedAsync(search, status, employeeId, facilityId, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _contractRepository.AddAsync(contract, cancellationToken);
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
return contract;
|
||||
}
|
||||
|
||||
public async Task<Contract?> UpdateAsync(Guid id, Contract updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contract = await _contractRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (contract is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
contract.ContractType = updates.ContractType;
|
||||
contract.EmployeeId = updates.EmployeeId;
|
||||
contract.FacilityId = updates.FacilityId;
|
||||
contract.StartDate = updates.StartDate;
|
||||
contract.EndDate = updates.EndDate;
|
||||
contract.Status = updates.Status;
|
||||
contract.WeeklyHours = updates.WeeklyHours;
|
||||
contract.HourlyWage = updates.HourlyWage;
|
||||
contract.AllowancesDescription = updates.AllowancesDescription;
|
||||
contract.OvertimeRules = updates.OvertimeRules;
|
||||
contract.VacationDaysPerYear = updates.VacationDaysPerYear;
|
||||
contract.ProbationPeriodMonths = updates.ProbationPeriodMonths;
|
||||
contract.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _contractRepository.UpdateAsync(contract, cancellationToken);
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
return contract;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ public enum CreateUserFailureReason
|
||||
EmployeeEmailMissing,
|
||||
UsernameTaken,
|
||||
RoleNotFound,
|
||||
InitialPasswordRequired
|
||||
InitialPasswordRequired,
|
||||
PasswordTooShort
|
||||
}
|
||||
|
||||
public class CreateUserResult
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum DeleteValueListItemFailureReason
|
||||
{
|
||||
ItemNotFound,
|
||||
InUse
|
||||
}
|
||||
|
||||
public class DeleteValueListItemResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public DeleteValueListItemFailureReason? FailureReason { get; init; }
|
||||
public IReadOnlyList<ValueListUsageEntry> Usages { get; init; } = Array.Empty<ValueListUsageEntry>();
|
||||
|
||||
public static DeleteValueListItemResult Ok() => new() { Success = true };
|
||||
|
||||
public static DeleteValueListItemResult Fail(DeleteValueListItemFailureReason reason, IReadOnlyList<ValueListUsageEntry>? usages = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason,
|
||||
Usages = usages ?? Array.Empty<ValueListUsageEntry>()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class FacilityContactService : IFacilityContactService
|
||||
{
|
||||
private readonly IFacilityContactRepository _facilityContactRepository;
|
||||
|
||||
public FacilityContactService(IFacilityContactRepository facilityContactRepository)
|
||||
{
|
||||
_facilityContactRepository = facilityContactRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default)
|
||||
=> _facilityContactRepository.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
|
||||
public Task<FacilityContact?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _facilityContactRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<FacilityContact> CreateAsync(FacilityContact contact, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _facilityContactRepository.AddAsync(contact, cancellationToken);
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
return contact;
|
||||
}
|
||||
|
||||
public async Task<FacilityContact?> UpdateAsync(Guid id, FacilityContact updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contact = await _facilityContactRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (contact is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
contact.Name = updates.Name;
|
||||
contact.Role = updates.Role;
|
||||
contact.Department = updates.Department;
|
||||
contact.PhoneNumber = updates.PhoneNumber;
|
||||
contact.Email = updates.Email;
|
||||
contact.Notes = updates.Notes;
|
||||
contact.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _facilityContactRepository.UpdateAsync(contact, cancellationToken);
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
return contact;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class FacilityService : IFacilityService
|
||||
{
|
||||
private readonly IFacilityRepository _facilityRepository;
|
||||
|
||||
public FacilityService(IFacilityRepository facilityRepository)
|
||||
{
|
||||
_facilityRepository = facilityRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Facility>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetPagedAsync(search, crmStatus, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Facility> CreateAsync(Facility facility, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _facilityRepository.AddAsync(facility, cancellationToken);
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
return facility;
|
||||
}
|
||||
|
||||
public async Task<Facility?> UpdateAsync(Guid id, Facility updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var facility = await _facilityRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (facility is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
facility.Name = updates.Name;
|
||||
facility.FacilityType = updates.FacilityType;
|
||||
facility.Website = updates.Website;
|
||||
facility.Street = updates.Street;
|
||||
facility.PostalCode = updates.PostalCode;
|
||||
facility.City = updates.City;
|
||||
facility.Country = updates.Country;
|
||||
facility.BillingStreet = updates.BillingStreet;
|
||||
facility.BillingPostalCode = updates.BillingPostalCode;
|
||||
facility.BillingCity = updates.BillingCity;
|
||||
facility.BillingCountry = updates.BillingCountry;
|
||||
facility.CrmStatus = updates.CrmStatus;
|
||||
facility.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _facilityRepository.UpdateAsync(facility, cancellationToken);
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
return facility;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IContractService
|
||||
{
|
||||
Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
Guid? employeeId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<Contract?> UpdateAsync(Guid id, Contract updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IFacilityContactService
|
||||
{
|
||||
Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task<FacilityContact?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<FacilityContact> CreateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task<FacilityContact?> UpdateAsync(Guid id, FacilityContact updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IFacilityService
|
||||
{
|
||||
Task<IReadOnlyList<Facility>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Facility> CreateAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task<Facility?> UpdateAsync(Guid id, Facility updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IOrderService
|
||||
{
|
||||
Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Order> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Order> CreateAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task<UpdateOrderResult> UpdateAsync(Guid id, Order updates, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ public interface IPasswordResetService
|
||||
|
||||
Task<PasswordResetVerifyResult> VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default);
|
||||
Task<ResetPasswordResult> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Stellt einen PIN für eine Account-Einladung aus (admin-ausgelöst, kein Self-Service-Request des Users
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
@@ -6,4 +7,13 @@ public interface IRoleService
|
||||
{
|
||||
Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<CreateRoleResult> CreateAsync(string name, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Inklusive RolePermissions - für die Rechte-Matrix-Ansicht/-Bearbeitung einer Rolle.</summary>
|
||||
Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Ersetzt die komplette RolePermission-Menge der Rolle durch die übergebene Menge (kein inkrementelles Patchen).</summary>
|
||||
Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
||||
Guid roleId,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action)> permissions,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IUserService
|
||||
@@ -13,7 +16,7 @@ public interface IUserService
|
||||
TimeSpan? invitePinValidity,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ChangeOwnPasswordAsync(
|
||||
Task<ChangeOwnPasswordResult> ChangeOwnPasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
@@ -33,4 +36,22 @@ public interface IUserService
|
||||
Guid roleId,
|
||||
bool isActive,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Individuelle Rechte-Ausnahmen eines Users (überschreiben den Rollen-Default, siehe PermissionService).</summary>
|
||||
Task<IReadOnlyList<PermissionOverrideSummary>?> GetPermissionOverridesAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Upsert: existiert bereits ein Override für (module, action) bei diesem User, wird dessen Effect aktualisiert statt dupliziert.</summary>
|
||||
Task<AddPermissionOverrideResult> AddPermissionOverrideAsync(
|
||||
Guid userId,
|
||||
ModuleType module,
|
||||
PermissionAction action,
|
||||
PermissionEffect effect,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
||||
Guid userId,
|
||||
Guid overrideId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IValueListService
|
||||
{
|
||||
Task<IReadOnlyList<ValueList>> GetListsAsync(CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<ValueListItem>> GetItemsAsync(string key, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ValueListItem> CreateItemAsync(string key, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> UpdateItemAsync(Guid id, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default);
|
||||
Task<DeleteValueListItemResult> DeleteItemAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ValueListUsageEntry>> GetUsagesAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ValueListItemTransition>> GetTransitionsAsync(string key, CancellationToken cancellationToken = default);
|
||||
Task ReplaceTransitionsAsync(string key, IEnumerable<(Guid FromItemId, Guid ToItemId)> transitions, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class OrderService : IOrderService
|
||||
{
|
||||
private const string StatusListKey = "OrderStatus";
|
||||
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public OrderService(IOrderRepository orderRepository, IValueListRepository valueListRepository)
|
||||
{
|
||||
_orderRepository = orderRepository;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Order>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Order> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetPagedAsync(search, statusId, facilityId, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Order> CreateAsync(Order order, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var initialStatus = await _valueListRepository.GetInitialItemAsync(StatusListKey, cancellationToken)
|
||||
?? throw new InvalidOperationException("Kein initialer Auftragsstatus konfiguriert (DbSeeder.SeedValueListsAsync fehlt).");
|
||||
order.StatusId = initialStatus.Id;
|
||||
|
||||
await _orderRepository.AddAsync(order, cancellationToken);
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
return order;
|
||||
}
|
||||
|
||||
public async Task<UpdateOrderResult> UpdateAsync(Guid id, Order updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var order = await _orderRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (order is null)
|
||||
{
|
||||
return UpdateOrderResult.Fail(UpdateOrderFailureReason.OrderNotFound);
|
||||
}
|
||||
|
||||
if (!await _valueListRepository.CanTransitionAsync(order.StatusId, updates.StatusId, cancellationToken))
|
||||
{
|
||||
return UpdateOrderResult.Fail(UpdateOrderFailureReason.InvalidStatusTransition);
|
||||
}
|
||||
|
||||
order.FacilityId = updates.FacilityId;
|
||||
order.FacilityContactId = updates.FacilityContactId;
|
||||
order.StartDate = updates.StartDate;
|
||||
order.EndDate = updates.EndDate;
|
||||
order.RequiredQualification = updates.RequiredQualification;
|
||||
order.ShiftType = updates.ShiftType;
|
||||
order.RequiredHeadcount = updates.RequiredHeadcount;
|
||||
order.Conditions = updates.Conditions;
|
||||
order.Priority = updates.Priority;
|
||||
order.StatusId = updates.StatusId;
|
||||
order.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _orderRepository.UpdateAsync(order, cancellationToken);
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateOrderResult.Ok(order);
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,22 @@ public class PasswordResetService : IPasswordResetService
|
||||
private readonly IPasswordResetCodeGenerator _codeGenerator;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IPasswordPolicy _passwordPolicy;
|
||||
|
||||
public PasswordResetService(
|
||||
IUserRepository userRepository,
|
||||
IPasswordResetCodeRepository codeRepository,
|
||||
IPasswordResetCodeGenerator codeGenerator,
|
||||
IPasswordHasher passwordHasher,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IPasswordPolicy passwordPolicy)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_codeRepository = codeRepository;
|
||||
_codeGenerator = codeGenerator;
|
||||
_passwordHasher = passwordHasher;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_passwordPolicy = passwordPolicy;
|
||||
}
|
||||
|
||||
public async Task<PasswordResetRequestResult> RequestResetAsync(string username, CancellationToken cancellationToken = default)
|
||||
@@ -111,13 +114,18 @@ public class PasswordResetService : IPasswordResetService
|
||||
return rawPin;
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default)
|
||||
public async Task<ResetPasswordResult> ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resetTokenHash = _codeGenerator.HashResetToken(resetToken);
|
||||
var code = await _codeRepository.GetByResetTokenHashAsync(resetTokenHash, cancellationToken);
|
||||
if (code is null || !code.IsResetTokenActive)
|
||||
{
|
||||
return false;
|
||||
return ResetPasswordResult.InvalidOrExpiredToken;
|
||||
}
|
||||
|
||||
if (!_passwordPolicy.IsValid(newPassword))
|
||||
{
|
||||
return ResetPasswordResult.PasswordTooShort;
|
||||
}
|
||||
|
||||
code.ResetTokenUsedAt = DateTime.UtcNow;
|
||||
@@ -134,6 +142,6 @@ public class PasswordResetService : IPasswordResetService
|
||||
|
||||
await _codeRepository.SaveChangesAsync(cancellationToken);
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
return ResetPasswordResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum RemovePermissionOverrideFailureReason
|
||||
{
|
||||
UserNotFound,
|
||||
OverrideNotFound
|
||||
}
|
||||
|
||||
public class RemovePermissionOverrideResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public RemovePermissionOverrideFailureReason? FailureReason { get; init; }
|
||||
|
||||
public static RemovePermissionOverrideResult Fail(RemovePermissionOverrideFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static RemovePermissionOverrideResult Ok() => new() { Success = true };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum ResetPasswordResult
|
||||
{
|
||||
Success,
|
||||
InvalidOrExpiredToken,
|
||||
PasswordTooShort
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
@@ -33,4 +34,29 @@ public class RoleService : IRoleService
|
||||
|
||||
return CreateRoleResult.Ok(role);
|
||||
}
|
||||
|
||||
public Task<Role?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _roleRepository.GetByIdWithPermissionsAsync(id, cancellationToken);
|
||||
|
||||
public async Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
||||
Guid roleId,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action)> permissions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var role = await _roleRepository.GetByIdWithPermissionsAsync(roleId, cancellationToken);
|
||||
if (role is null)
|
||||
{
|
||||
return UpdateRolePermissionsResult.Fail(UpdateRolePermissionsFailureReason.RoleNotFound);
|
||||
}
|
||||
|
||||
role.RolePermissions.Clear();
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var newPermissions = permissions.Distinct()
|
||||
.Select(p => new RolePermission { RoleId = role.Id, Module = p.Module, Action = p.Action })
|
||||
.ToList();
|
||||
await _roleRepository.AddPermissionRangeAsync(newPermissions, cancellationToken);
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateRolePermissionsResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum UpdateOrderFailureReason
|
||||
{
|
||||
OrderNotFound,
|
||||
InvalidStatusTransition
|
||||
}
|
||||
|
||||
public class UpdateOrderResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public UpdateOrderFailureReason? FailureReason { get; init; }
|
||||
public Order? Order { get; init; }
|
||||
|
||||
public static UpdateOrderResult Fail(UpdateOrderFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static UpdateOrderResult Ok(Order order) => new()
|
||||
{
|
||||
Success = true,
|
||||
Order = order
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum UpdateRolePermissionsFailureReason
|
||||
{
|
||||
RoleNotFound
|
||||
}
|
||||
|
||||
public class UpdateRolePermissionsResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public UpdateRolePermissionsFailureReason? FailureReason { get; init; }
|
||||
|
||||
public static UpdateRolePermissionsResult Fail(UpdateRolePermissionsFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static UpdateRolePermissionsResult Ok() => new() { Success = true };
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
@@ -11,6 +13,7 @@ public class UserService : IUserService
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IPasswordPolicy _passwordPolicy;
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
@@ -18,7 +21,8 @@ public class UserService : IUserService
|
||||
IRoleRepository roleRepository,
|
||||
IPasswordHasher passwordHasher,
|
||||
IPasswordResetService passwordResetService,
|
||||
IRefreshTokenRepository refreshTokenRepository)
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IPasswordPolicy passwordPolicy)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_employeeRepository = employeeRepository;
|
||||
@@ -26,6 +30,7 @@ public class UserService : IUserService
|
||||
_passwordHasher = passwordHasher;
|
||||
_passwordResetService = passwordResetService;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_passwordPolicy = passwordPolicy;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserSummary>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
@@ -73,6 +78,11 @@ public class UserService : IUserService
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.InitialPasswordRequired);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct && !_passwordPolicy.IsValid(initialPassword))
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.PasswordTooShort);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(employee.Email))
|
||||
{
|
||||
return CreateUserResult.Fail(CreateUserFailureReason.EmployeeEmailMissing);
|
||||
@@ -104,7 +114,7 @@ public class UserService : IUserService
|
||||
return CreateUserResult.Ok(user.Id, role.Name);
|
||||
}
|
||||
|
||||
public async Task<bool> ChangeOwnPasswordAsync(
|
||||
public async Task<ChangeOwnPasswordResult> ChangeOwnPasswordAsync(
|
||||
Guid userId,
|
||||
string currentPassword,
|
||||
string newPassword,
|
||||
@@ -113,7 +123,12 @@ public class UserService : IUserService
|
||||
var user = await _userRepository.GetByIdAsync(userId, cancellationToken);
|
||||
if (user is null || !_passwordHasher.Verify(currentPassword, user.PasswordHash))
|
||||
{
|
||||
return false;
|
||||
return ChangeOwnPasswordResult.InvalidCurrentPassword;
|
||||
}
|
||||
|
||||
if (!_passwordPolicy.IsValid(newPassword))
|
||||
{
|
||||
return ChangeOwnPasswordResult.PasswordTooShort;
|
||||
}
|
||||
|
||||
user.PasswordHash = _passwordHasher.Hash(newPassword);
|
||||
@@ -131,7 +146,7 @@ public class UserService : IUserService
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
await _refreshTokenRepository.SaveChangesAsync(cancellationToken);
|
||||
return true;
|
||||
return ChangeOwnPasswordResult.Success;
|
||||
}
|
||||
|
||||
public async Task<AdminResetPasswordResult> AdminResetPasswordAsync(
|
||||
@@ -152,6 +167,11 @@ public class UserService : IUserService
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.InitialPasswordRequired);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Direct && !_passwordPolicy.IsValid(initialPassword))
|
||||
{
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.PasswordTooShort);
|
||||
}
|
||||
|
||||
if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(user.Employee?.Email))
|
||||
{
|
||||
return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.EmployeeEmailMissing);
|
||||
@@ -219,4 +239,68 @@ public class UserService : IUserService
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateUserResult.Ok();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionOverrideSummary>?> GetPermissionOverridesAsync(
|
||||
Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.PermissionOverrides
|
||||
.Select(o => new PermissionOverrideSummary(o.Id, o.Module, o.Action, o.Effect))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<AddPermissionOverrideResult> AddPermissionOverrideAsync(
|
||||
Guid userId,
|
||||
ModuleType module,
|
||||
PermissionAction action,
|
||||
PermissionEffect effect,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return AddPermissionOverrideResult.Fail(AddPermissionOverrideFailureReason.UserNotFound);
|
||||
}
|
||||
|
||||
var existing = user.PermissionOverrides
|
||||
.FirstOrDefault(o => o.Module == module && o.Action == action);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Effect = effect;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing = new UserPermissionOverride { UserId = user.Id, Module = module, Action = action, Effect = effect };
|
||||
await _userRepository.AddPermissionOverrideAsync(existing, cancellationToken);
|
||||
}
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
return AddPermissionOverrideResult.Ok(new PermissionOverrideSummary(existing.Id, module, action, effect));
|
||||
}
|
||||
|
||||
public async Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
||||
Guid userId, Guid overrideId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return RemovePermissionOverrideResult.Fail(RemovePermissionOverrideFailureReason.UserNotFound);
|
||||
}
|
||||
|
||||
var existing = user.PermissionOverrides.FirstOrDefault(o => o.Id == overrideId);
|
||||
if (existing is null)
|
||||
{
|
||||
return RemovePermissionOverrideResult.Fail(RemovePermissionOverrideFailureReason.OverrideNotFound);
|
||||
}
|
||||
|
||||
user.PermissionOverrides.Remove(existing);
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
return RemovePermissionOverrideResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class ValueListService : IValueListService
|
||||
{
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
private readonly IEnumerable<IValueListUsageChecker> _usageCheckers;
|
||||
|
||||
public ValueListService(IValueListRepository valueListRepository, IEnumerable<IValueListUsageChecker> usageCheckers)
|
||||
{
|
||||
_valueListRepository = valueListRepository;
|
||||
_usageCheckers = usageCheckers;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ValueList>> GetListsAsync(CancellationToken cancellationToken = default)
|
||||
=> _valueListRepository.GetAllListsAsync(cancellationToken);
|
||||
|
||||
public Task<IReadOnlyList<ValueListItem>> GetItemsAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _valueListRepository.GetItemsAsync(key, cancellationToken);
|
||||
|
||||
public async Task<ValueListItem> CreateItemAsync(string key, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = await _valueListRepository.GetListByKeyAsync(key, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Unbekannte Auswahlliste '{key}'.");
|
||||
|
||||
var item = new ValueListItem
|
||||
{
|
||||
ValueListId = list.Id,
|
||||
Value = value,
|
||||
SortOrder = sortOrder,
|
||||
IsDefault = isDefault,
|
||||
IsInitial = isInitial,
|
||||
IsTerminal = isTerminal
|
||||
};
|
||||
|
||||
await _valueListRepository.AddItemAsync(item, cancellationToken);
|
||||
await _valueListRepository.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<ValueListItem?> UpdateItemAsync(Guid id, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await _valueListRepository.GetItemByIdAsync(id, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
item.Value = value;
|
||||
item.SortOrder = sortOrder;
|
||||
item.IsDefault = isDefault;
|
||||
item.IsInitial = isInitial;
|
||||
item.IsTerminal = isTerminal;
|
||||
|
||||
await _valueListRepository.UpdateItemAsync(item, cancellationToken);
|
||||
await _valueListRepository.SaveChangesAsync(cancellationToken);
|
||||
return item;
|
||||
}
|
||||
|
||||
public async Task<DeleteValueListItemResult> DeleteItemAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await _valueListRepository.GetItemByIdAsync(id, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
return DeleteValueListItemResult.Fail(DeleteValueListItemFailureReason.ItemNotFound);
|
||||
}
|
||||
|
||||
var usages = await FindUsagesAsync(item, cancellationToken);
|
||||
if (usages.Count > 0)
|
||||
{
|
||||
return DeleteValueListItemResult.Fail(DeleteValueListItemFailureReason.InUse, usages);
|
||||
}
|
||||
|
||||
await _valueListRepository.RemoveItemAsync(item, cancellationToken);
|
||||
await _valueListRepository.SaveChangesAsync(cancellationToken);
|
||||
return DeleteValueListItemResult.Ok();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ValueListUsageEntry>> GetUsagesAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await _valueListRepository.GetItemByIdAsync(id, cancellationToken);
|
||||
return item is null ? Array.Empty<ValueListUsageEntry>() : await FindUsagesAsync(item, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ValueListItemTransition>> GetTransitionsAsync(string key, CancellationToken cancellationToken = default)
|
||||
=> _valueListRepository.GetTransitionsAsync(key, cancellationToken);
|
||||
|
||||
public async Task ReplaceTransitionsAsync(string key, IEnumerable<(Guid FromItemId, Guid ToItemId)> transitions, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _valueListRepository.ReplaceTransitionsAsync(key, transitions, cancellationToken);
|
||||
await _valueListRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ValueListUsageEntry>> FindUsagesAsync(ValueListItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var checker = _usageCheckers.FirstOrDefault(c => c.Key == item.ValueList.Key);
|
||||
return checker is null
|
||||
? Array.Empty<ValueListUsageEntry>()
|
||||
: await checker.FindUsagesAsync(item.Id, item.Value, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OmsorgCore.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Markiert eine Property als sensibel - der SaveChanges-Interceptor nimmt ihren Feld-Diff zwar als
|
||||
/// "geändert" auf, ersetzt den tatsächlichen Wert im Audit-Log aber durch einen Platzhalter (kein
|
||||
/// Klartext-/Hash-Leck von Secrets in eine für Geschäftsführung lesbare Tabelle).
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class AuditRedactedAttribute : Attribute
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Ein Eintrag im generischen Audit-Log: "wer hat wann was verändert" (Entity-Changes, per
|
||||
/// SaveChanges-Interceptor automatisch erfasst) bzw. "wer hat wann was getan" (nicht-Entity-Aktionen
|
||||
/// wie Login/Logout/Session-Kill, per AuditEvent explizit gemeldet). Nicht zu verwechseln mit
|
||||
/// <see cref="LoginAttempt"/> (reine Brute-Force-Sperr-Historie).
|
||||
/// Erbt bewusst von <see cref="Entity"/>, nicht <see cref="AuditableEntity"/> - ein Audit-Eintrag ist
|
||||
/// selbst unveränderlich und braucht kein CreatedAt/UpdatedAt/Soft-Delete.
|
||||
/// </summary>
|
||||
public class AuditLogEntry : Entity
|
||||
{
|
||||
public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public Guid? ActorUserId { get; set; }
|
||||
|
||||
/// <summary>Snapshot, bleibt lesbar auch wenn der User später umbenannt oder gelöscht wird.</summary>
|
||||
public string? ActorUsername { get; set; }
|
||||
|
||||
public string? IpAddress { get; set; }
|
||||
|
||||
public AuditEventCategory Category { get; set; }
|
||||
|
||||
/// <summary>Z. B. "Created"/"Updated"/"Deleted"/"Login"/"LoginFailed"/"Logout"/"SessionRevoked"/"AllSessionsRevoked".</summary>
|
||||
public string Action { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Nur bei Category = EntityChange gesetzt, z. B. "Employee".</summary>
|
||||
public string? EntityType { get; set; }
|
||||
public Guid? EntityId { get; set; }
|
||||
|
||||
/// <summary>JSON: Feld-Diff bei EntityChange, freier Kontext bei BehavioralEvent.</summary>
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
@@ -19,4 +19,11 @@ public class Contract : AuditableEntity
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly? EndDate { get; set; }
|
||||
public string Status { get; set; } = "Entwurf";
|
||||
|
||||
public decimal? WeeklyHours { get; set; }
|
||||
public decimal? HourlyWage { get; set; }
|
||||
public string? AllowancesDescription { get; set; }
|
||||
public string? OvertimeRules { get; set; }
|
||||
public int? VacationDaysPerYear { get; set; }
|
||||
public int? ProbationPeriodMonths { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,14 +4,24 @@ namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Core-Objekt "Einrichtung" — CRM-/Kundendatensatz (REQUIREMENTS.md Abschnitt 6, Blueprint 19.2).
|
||||
/// Adresse und Rechnungsadresse sind getrennte Adressen (z. B. Einsatzort vs. zentrale Buchhaltung
|
||||
/// eines Trägers), daher jeweils eigene strukturierte Felder statt eines gemeinsamen Freitextfelds.
|
||||
/// </summary>
|
||||
public class Facility : AuditableEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? FacilityType { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? BillingAddress { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Website { get; set; }
|
||||
|
||||
public string? Street { get; set; }
|
||||
public string? PostalCode { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Country { get; set; }
|
||||
|
||||
public string? BillingStreet { get; set; }
|
||||
public string? BillingPostalCode { get; set; }
|
||||
public string? BillingCity { get; set; }
|
||||
public string? BillingCountry { get; set; }
|
||||
|
||||
public string CrmStatus { get; set; } = "Lead";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Ansprechpartner einer Einrichtung (REQUIREMENTS.md FR-EIN-2, Blueprint 19.2) — 1:n-Beziehung
|
||||
/// zu <see cref="Facility"/>, da eine Einrichtung mehrere Ansprechpartner haben kann.
|
||||
/// </summary>
|
||||
public class FacilityContact : AuditableEntity
|
||||
{
|
||||
public Guid FacilityId { get; set; }
|
||||
public Facility Facility { get; set; } = null!;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Role { get; set; }
|
||||
public string? Department { get; set; }
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -10,8 +10,17 @@ public class Order : AuditableEntity
|
||||
public Guid FacilityId { get; set; }
|
||||
public Facility Facility { get; set; } = null!;
|
||||
|
||||
public Guid? FacilityContactId { get; set; }
|
||||
public FacilityContact? FacilityContact { get; set; }
|
||||
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly? EndDate { get; set; }
|
||||
public string? RequiredQualification { get; set; }
|
||||
public string Status { get; set; } = "Anfrage";
|
||||
public string? ShiftType { get; set; }
|
||||
public int RequiredHeadcount { get; set; } = 1;
|
||||
public string? Conditions { get; set; }
|
||||
public string Priority { get; set; } = "Normal";
|
||||
|
||||
public Guid StatusId { get; set; }
|
||||
public ValueListItem Status { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace OmsorgCore.Domain.Entities;
|
||||
public class User : AuditableEntity
|
||||
{
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
[AuditRedacted]
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
@@ -17,6 +19,7 @@ public class User : AuditableEntity
|
||||
/// Wird als Claim in jeden Access-Token eingebettet und bei jedem Request dagegen geprüft -
|
||||
/// ein Wechsel invalidiert sofort alle bereits ausgestellten Access-Tokens dieses Users.
|
||||
/// </summary>
|
||||
[AuditRedacted]
|
||||
public Guid SecurityStamp { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Eine admin-editierbare Auswahlliste (z. B. Mitarbeiterstatus, Beschäftigungsart, CRM-Status,
|
||||
/// Einrichtungstyp, Vertragstyp/-status, Auftragsstatus) — Stammdaten statt Enum/hartcodiertes
|
||||
/// Frontend-Array, damit sie über die "Status-Verwaltung" (Einstellungen) ohne Code-Deploy gepflegt
|
||||
/// werden kann. <see cref="Key"/> ist der stabile Bezeichner, über den Backend-Code (Validierung,
|
||||
/// Verwendungsprüfung) und Frontend (Dropdown-Ladeaufruf) auf eine bestimmte Liste verweisen.
|
||||
/// </summary>
|
||||
public class ValueList : Entity
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public ICollection<ValueListItem> Items { get; set; } = new List<ValueListItem>();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Ein einzelner Wert innerhalb einer <see cref="ValueList"/> (z. B. "Aktiv" in der Liste
|
||||
/// "EmployeeStatus"). <see cref="IsInitial"/>/<see cref="IsTerminal"/> sind nur für Listen mit
|
||||
/// Übergangsregeln relevant (aktuell nur "OrderStatus", siehe <see cref="ValueListItemTransition"/>)
|
||||
/// und bei allen anderen Listen einfach <c>false</c>.
|
||||
/// </summary>
|
||||
public class ValueListItem : Entity
|
||||
{
|
||||
public Guid ValueListId { get; set; }
|
||||
public ValueList ValueList { get; set; } = null!;
|
||||
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsInitial { get; set; }
|
||||
public bool IsTerminal { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using OmsorgCore.Domain.Common;
|
||||
|
||||
namespace OmsorgCore.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Ein erlaubter Übergang zwischen zwei <see cref="ValueListItem"/>n derselben Liste. Wird aktuell
|
||||
/// nur für die Liste "OrderStatus" befüllt (FR-EM-2) — die meisten Listen brauchen keine
|
||||
/// Übergangsregeln und lassen diese Tabelle für ihren <see cref="ValueList.Key"/> leer.
|
||||
/// </summary>
|
||||
public class ValueListItemTransition : Entity
|
||||
{
|
||||
public Guid FromItemId { get; set; }
|
||||
public ValueListItem FromItem { get; set; } = null!;
|
||||
|
||||
public Guid ToItemId { get; set; }
|
||||
public ValueListItem ToItem { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace OmsorgCore.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Unterscheidet automatisch vom SaveChanges-Interceptor erfasste Entity-Änderungen von explizit
|
||||
/// gemeldeten Verhaltens-Ereignissen ohne Entity-Änderung (Login, Logout, Session-Kill, ...).
|
||||
/// </summary>
|
||||
public enum AuditEventCategory
|
||||
{
|
||||
EntityChange,
|
||||
BehavioralEvent
|
||||
}
|
||||
@@ -13,5 +13,6 @@ public enum ModuleType
|
||||
Invoices,
|
||||
Recruiting,
|
||||
Controlling,
|
||||
UserManagement
|
||||
UserManagement,
|
||||
AuditLog
|
||||
}
|
||||
|
||||
@@ -10,8 +10,12 @@ public static class DependencyInjection
|
||||
{
|
||||
services.AddSingleton<IDomainEventDispatcher, DomainEventDispatcher>();
|
||||
services.AddScoped<IDomainEventHandler<EmployeeCreatedEvent>, EmployeeCreatedHandler>();
|
||||
services.AddScoped<IDomainEventHandler<FacilityCreatedEvent>, FacilityCreatedHandler>();
|
||||
services.AddScoped<IDomainEventHandler<ContractCreatedEvent>, ContractCreatedHandler>();
|
||||
services.AddScoped<IDomainEventHandler<OrderCreatedEvent>, OrderCreatedHandler>();
|
||||
services.AddScoped<IDomainEventHandler<PasswordResetRequestedEvent>, PasswordResetRequestedHandler>();
|
||||
services.AddScoped<IDomainEventHandler<UserInvitedEvent>, UserInvitedHandler>();
|
||||
services.AddScoped<IDomainEventHandler<AuditEvent>, AuditEventHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace OmsorgCore.Engine.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Generisches Ereignis für nicht-Entity-Aktionen (Login/Logout/Session-Kill/...), die der
|
||||
/// SaveChanges-Interceptor nicht automatisch erfasst, weil keine AuditableEntity verändert wird.
|
||||
/// Ein Dispatch-Aufruf pro Aktionsstelle im Api-Layer, analog zu <see cref="EmployeeCreatedEvent"/>.
|
||||
/// </summary>
|
||||
public class AuditEvent : IDomainEvent
|
||||
{
|
||||
public Guid? ActorUserId { get; }
|
||||
public string? ActorUsername { get; }
|
||||
public string? IpAddress { get; }
|
||||
public string Action { get; }
|
||||
public string? Details { get; }
|
||||
public DateTime OccurredAt { get; }
|
||||
|
||||
public AuditEvent(Guid? actorUserId, string? actorUsername, string? ipAddress, string action, string? details = null)
|
||||
{
|
||||
ActorUserId = actorUserId;
|
||||
ActorUsername = actorUsername;
|
||||
IpAddress = ipAddress;
|
||||
Action = action;
|
||||
Details = details;
|
||||
OccurredAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OmsorgCore.Engine.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Ereignis für neu angelegte Verträge (Core-Objekt "Vertrag", REQUIREMENTS.md Abschnitt 6).
|
||||
/// </summary>
|
||||
public class ContractCreatedEvent : IDomainEvent
|
||||
{
|
||||
public Guid ContractId { get; }
|
||||
public DateTime OccurredAt { get; }
|
||||
|
||||
public ContractCreatedEvent(Guid contractId)
|
||||
{
|
||||
ContractId = contractId;
|
||||
OccurredAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace OmsorgCore.Engine.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Ereignis für neu angelegte Einrichtungen (Core-Objekt "Einrichtung", REQUIREMENTS.md Abschnitt 6).
|
||||
/// </summary>
|
||||
public class FacilityCreatedEvent : IDomainEvent
|
||||
{
|
||||
public Guid FacilityId { get; }
|
||||
public DateTime OccurredAt { get; }
|
||||
|
||||
public FacilityCreatedEvent(Guid facilityId)
|
||||
{
|
||||
FacilityId = facilityId;
|
||||
OccurredAt = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user