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:
Felix Kemmler
2026-08-08 23:29:16 +02:00
co-authored by Claude Sonnet 5
parent ee74ed65f5
commit e9e96a57dc
528 changed files with 96814 additions and 456 deletions
@@ -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);
}
+31 -1
View File
@@ -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