Migrate omsorgapp to browser SPA, add Docker/CI build setup
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 4s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 4s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 3s
- omsorgapp: drop Electron, run as a plain Vite/React browser app; refresh token moves to an HttpOnly cookie (omsorgCore), CORS added for the new browser origin, document download/preview switched to Blob-based browser APIs. - Add Dockerfiles for omsorgCore, omsorgapp, and omsorgWeb, a docker-compose.yml wiring Postgres/MySQL/all three apps together, and a Gitea Actions workflow that builds and pushes images to the repo's container registry on push to main and on version tags. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e9e96a57dc
commit
598dfcd38a
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AbsenceDecisionRequest(string Status, string? AdminNote);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AbsenceResponse(
|
||||
Guid Id,
|
||||
Guid EmployeeId,
|
||||
string EmployeeName,
|
||||
string Type,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? Reason,
|
||||
string? Substitute,
|
||||
string? Note,
|
||||
string Status,
|
||||
string? AdminNote,
|
||||
DateTime CreatedAt);
|
||||
@@ -2,4 +2,4 @@ using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record AddUserPermissionOverrideRequest(ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
public record AddUserPermissionOverrideRequest(ModuleType Module, PermissionAction Action, PermissionEffect Effect, PermissionScope Scope);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateAbsenceRequest(
|
||||
string Type,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? Reason,
|
||||
string? Substitute,
|
||||
string? Note);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateFacilityQualificationRateRequest(
|
||||
string Qualification,
|
||||
decimal Rate);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record CreateTimeEntryRequest(
|
||||
Guid OrderId,
|
||||
DateOnly Date,
|
||||
TimeOnly Start,
|
||||
TimeOnly End,
|
||||
TimeSpan BreakDuration,
|
||||
decimal NightHours,
|
||||
decimal SaturdayHours,
|
||||
decimal SundayHours,
|
||||
decimal HolidayHours);
|
||||
@@ -5,4 +5,5 @@ public record CreateValueListItemRequest(
|
||||
int SortOrder,
|
||||
bool IsDefault = false,
|
||||
bool IsInitial = false,
|
||||
bool IsTerminal = false);
|
||||
bool IsTerminal = false,
|
||||
bool TriggersFollowUp = false);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record DocumentResponse(
|
||||
Guid Id,
|
||||
string EntityType,
|
||||
Guid EntityId,
|
||||
string Category,
|
||||
string FileName,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
string? Description,
|
||||
Guid UploadedByUserId,
|
||||
string? UploadedByUsername,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record FacilityQualificationRateResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string Qualification,
|
||||
decimal Rate);
|
||||
@@ -13,4 +13,16 @@ public record FacilityResponse(
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
string? BillingCountry,
|
||||
DateTime? FollowUpDueDate,
|
||||
decimal? BillingRate,
|
||||
decimal? NightSurchargePercent,
|
||||
decimal? SaturdaySurchargePercent,
|
||||
decimal? SundaySurchargePercent,
|
||||
decimal? HolidaySurchargePercent,
|
||||
decimal? TravelCostRate,
|
||||
decimal? MinimumHours,
|
||||
string? BreakPolicy,
|
||||
string? BillingInterval,
|
||||
int? PaymentTermDays,
|
||||
string? IndividualAgreements);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
/// <summary>"sent" | "cannot_reset" - siehe AuthController.ForgotPasswordRequest für die Anti-Enumeration-Abwägung.</summary>
|
||||
/// <summary>
|
||||
/// "sent" | "cannot_reset" | "email_unavailable" - siehe AuthController.ForgotPasswordRequest für die
|
||||
/// Anti-Enumeration-Abwägung. "email_unavailable": Reset-Code wurde angelegt, aber der E-Mail-Versand
|
||||
/// ist fehlgeschlagen (z.B. SMTP nicht erreichbar) - Client soll das ehrlich anzeigen statt zum
|
||||
/// PIN-Eingabe-Schritt weiterzuleiten.
|
||||
/// </summary>
|
||||
public record ForgotPasswordRequestResponse(string Status);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, bool MustChangePassword);
|
||||
public record LoginResponse(string AccessToken, DateTime ExpiresAt, bool MustChangePassword);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record LogoutRequest(string RefreshToken);
|
||||
@@ -2,4 +2,4 @@ using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record PermissionDto(ModuleType Module, PermissionAction Action);
|
||||
public record PermissionDto(ModuleType Module, PermissionAction Action, PermissionScope Scope);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record RefreshRequest(string RefreshToken);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TimeEntryDecisionRequest(Guid StatusId, string? AdminNote);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TimeEntryResponse(
|
||||
Guid Id,
|
||||
Guid EmployeeId,
|
||||
string EmployeeName,
|
||||
Guid OrderId,
|
||||
Guid FacilityId,
|
||||
string FacilityName,
|
||||
DateOnly Date,
|
||||
TimeOnly Start,
|
||||
TimeOnly End,
|
||||
TimeSpan BreakDuration,
|
||||
decimal NightHours,
|
||||
decimal SaturdayHours,
|
||||
decimal SundayHours,
|
||||
decimal HolidayHours,
|
||||
Guid StatusId,
|
||||
string StatusName,
|
||||
bool IsEditableByOwner,
|
||||
string? AdminNote,
|
||||
DateTime CreatedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashAbsenceResponse(
|
||||
Guid Id,
|
||||
string Type,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashContractResponse(
|
||||
Guid Id,
|
||||
string ContractType,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashEmployeeResponse(
|
||||
Guid Id,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashFacilityContactResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string Name,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashFacilityQualificationRateResponse(
|
||||
Guid Id,
|
||||
Guid FacilityId,
|
||||
string Qualification,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashFacilityResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashOrderResponse(
|
||||
Guid Id,
|
||||
string? RequiredQualification,
|
||||
DateTime? DeletedAt);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record TrashTimeEntryResponse(Guid Id, DateOnly Date, DateTime? DeletedAt);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateAbsenceRequest(
|
||||
string Type,
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
string? Reason,
|
||||
string? Substitute,
|
||||
string? Note);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateDocumentRequest(
|
||||
string Category,
|
||||
string? Description,
|
||||
string FileName);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateFacilityQualificationRateRequest(
|
||||
string Qualification,
|
||||
decimal Rate);
|
||||
@@ -12,4 +12,16 @@ public record UpdateFacilityRequest(
|
||||
string? BillingStreet,
|
||||
string? BillingPostalCode,
|
||||
string? BillingCity,
|
||||
string? BillingCountry);
|
||||
string? BillingCountry,
|
||||
int? FollowUpDays,
|
||||
decimal? BillingRate,
|
||||
decimal? NightSurchargePercent,
|
||||
decimal? SaturdaySurchargePercent,
|
||||
decimal? SundaySurchargePercent,
|
||||
decimal? HolidaySurchargePercent,
|
||||
decimal? TravelCostRate,
|
||||
decimal? MinimumHours,
|
||||
string? BreakPolicy,
|
||||
string? BillingInterval,
|
||||
int? PaymentTermDays,
|
||||
string? IndividualAgreements);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UpdateTimeEntryRequest(
|
||||
Guid OrderId,
|
||||
DateOnly Date,
|
||||
TimeOnly Start,
|
||||
TimeOnly End,
|
||||
TimeSpan BreakDuration,
|
||||
decimal NightHours,
|
||||
decimal SaturdayHours,
|
||||
decimal SundayHours,
|
||||
decimal HolidayHours);
|
||||
@@ -5,4 +5,5 @@ public record UpdateValueListItemRequest(
|
||||
int SortOrder,
|
||||
bool IsDefault = false,
|
||||
bool IsInitial = false,
|
||||
bool IsTerminal = false);
|
||||
bool IsTerminal = false,
|
||||
bool TriggersFollowUp = false);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UploadDocumentRequest(
|
||||
string EntityType,
|
||||
Guid EntityId,
|
||||
string Category,
|
||||
string? Description,
|
||||
IFormFile File);
|
||||
@@ -2,4 +2,4 @@ using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record UserPermissionOverrideResponse(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect);
|
||||
public record UserPermissionOverrideResponse(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect, PermissionScope Scope);
|
||||
|
||||
@@ -6,4 +6,5 @@ public record ValueListItemResponse(
|
||||
int SortOrder,
|
||||
bool IsDefault,
|
||||
bool IsInitial,
|
||||
bool IsTerminal);
|
||||
bool IsTerminal,
|
||||
bool TriggersFollowUp);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace OmsorgCore.Api.Contracts;
|
||||
|
||||
public record ValueListTransitionResponse(Guid Id, Guid FromItemId, Guid ToItemId);
|
||||
public record ValueListTransitionResponse(Guid Id, Guid FromItemId, Guid ToItemId, bool RequiresApproval);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Abwesenheits-/Urlaubs-/Krankmeldungsanträge (FR-CON-1, Datenbasis für FR-EM-3). Außendienst
|
||||
/// darf nur Create/View mit PermissionScope.Own (eigene Anträge, EmployeeId wird serverseitig aus
|
||||
/// dem JWT gesetzt, siehe AbsenceService.CreateAsync), Büro-Rollen sehen/entscheiden über alle.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/absences")]
|
||||
public class AbsencesController : ControllerBase
|
||||
{
|
||||
private const string AbsenceTypeListKey = "AbsenceType";
|
||||
private const string AbsenceStatusListKey = "AbsenceStatus";
|
||||
|
||||
private readonly IAbsenceService _absenceService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public AbsencesController(IAbsenceService absenceService, IValueListRepository valueListRepository)
|
||||
{
|
||||
_absenceService = absenceService;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<AbsenceResponse>>> GetAll(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] string? type,
|
||||
[FromQuery] Guid? employeeId,
|
||||
[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 _absenceService.GetPagedAsync(status, type, employeeId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<AbsenceResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.View)]
|
||||
public async Task<ActionResult<AbsenceResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var absence = await _absenceService.GetByIdAsync(id, cancellationToken);
|
||||
return absence is null ? NotFound() : Ok(ToResponse(absence));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Create)]
|
||||
public async Task<ActionResult<AbsenceResponse>> Create(CreateAbsenceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = await ValidateFieldsAsync(request.Type, request.StartDate, request.EndDate, request.Reason, request.Substitute, request.Note, cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var absence = new Absence
|
||||
{
|
||||
Type = request.Type,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Reason = request.Reason,
|
||||
Substitute = request.Substitute,
|
||||
Note = request.Note
|
||||
};
|
||||
|
||||
var created = await _absenceService.CreateAsync(absence, cancellationToken);
|
||||
if (created is null)
|
||||
{
|
||||
return BadRequest("Kein Mitarbeiter verknüpft - Abwesenheitsanträge können nur für einen verknüpften Mitarbeiter angelegt werden.");
|
||||
}
|
||||
|
||||
var reloaded = await _absenceService.GetByIdAsync(created.Id, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<AbsenceResponse>> Update(Guid id, UpdateAbsenceRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = await ValidateFieldsAsync(request.Type, request.StartDate, request.EndDate, request.Reason, request.Substitute, request.Note, cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var updates = new Absence
|
||||
{
|
||||
Type = request.Type,
|
||||
StartDate = request.StartDate,
|
||||
EndDate = request.EndDate,
|
||||
Reason = request.Reason,
|
||||
Substitute = request.Substitute,
|
||||
Note = request.Note
|
||||
};
|
||||
|
||||
var result = await _absenceService.UpdateAsync(id, updates, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason == UpdateAbsenceFailureReason.AlreadyDecided
|
||||
? BadRequest("Der Antrag wurde bereits entschieden und kann nicht mehr bearbeitet werden.")
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
var reloaded = await _absenceService.GetByIdAsync(result.Absence!.Id, cancellationToken);
|
||||
return Ok(ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/decision")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Approve)]
|
||||
public async Task<ActionResult<AbsenceResponse>> Decide(Guid id, AbsenceDecisionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var allowedStatuses = await _valueListRepository.GetActiveValuesAsync(AbsenceStatusListKey, cancellationToken);
|
||||
var initialStatus = await _absenceService.GetInitialStatusValueAsync(cancellationToken);
|
||||
var decidableStatuses = allowedStatuses.Where(s => s != initialStatus).ToList();
|
||||
if (string.IsNullOrWhiteSpace(request.Status) || !decidableStatuses.Contains(request.Status))
|
||||
{
|
||||
return BadRequest($"Status muss einer der folgenden Werte sein: {string.Join(", ", decidableStatuses)}.");
|
||||
}
|
||||
|
||||
if (request.AdminNote is { Length: > 500 })
|
||||
{
|
||||
return BadRequest("AdminNote darf maximal 500 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var decided = await _absenceService.DecideAsync(id, request.Status, request.AdminNote, cancellationToken);
|
||||
return decided is null ? NotFound() : Ok(ToResponse(decided));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _absenceService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFieldsAsync(
|
||||
string type,
|
||||
DateOnly startDate,
|
||||
DateOnly endDate,
|
||||
string? reason,
|
||||
string? substitute,
|
||||
string? note,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
return "Type ist erforderlich.";
|
||||
}
|
||||
|
||||
var allowedTypes = await _valueListRepository.GetActiveValuesAsync(AbsenceTypeListKey, cancellationToken);
|
||||
if (!allowedTypes.Contains(type))
|
||||
{
|
||||
return $"Type muss einer der folgenden Werte sein: {string.Join(", ", allowedTypes)}.";
|
||||
}
|
||||
|
||||
if (startDate == default)
|
||||
{
|
||||
return "StartDate ist erforderlich.";
|
||||
}
|
||||
|
||||
if (endDate < startDate)
|
||||
{
|
||||
return "EndDate darf nicht vor StartDate liegen.";
|
||||
}
|
||||
|
||||
if (reason is { Length: > 500 })
|
||||
{
|
||||
return "Reason darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (substitute is { Length: > 200 })
|
||||
{
|
||||
return "Substitute darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
if (note is { Length: > 500 })
|
||||
{
|
||||
return "Note darf maximal 500 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static AbsenceResponse ToResponse(Absence absence)
|
||||
=> new(
|
||||
absence.Id,
|
||||
absence.EmployeeId,
|
||||
absence.Employee is null ? string.Empty : $"{absence.Employee.FirstName} {absence.Employee.LastName}",
|
||||
absence.Type,
|
||||
absence.StartDate,
|
||||
absence.EndDate,
|
||||
absence.Reason,
|
||||
absence.Substitute,
|
||||
absence.Note,
|
||||
absence.Status,
|
||||
absence.AdminNote,
|
||||
absence.CreatedAt);
|
||||
}
|
||||
@@ -22,11 +22,13 @@ public class AdminEmailController : ControllerBase
|
||||
{
|
||||
private readonly IEmailSender _emailSender;
|
||||
private readonly EmailOptions _emailOptions;
|
||||
private readonly ILogger<AdminEmailController> _logger;
|
||||
|
||||
public AdminEmailController(IEmailSender emailSender, IOptions<EmailOptions> emailOptions)
|
||||
public AdminEmailController(IEmailSender emailSender, IOptions<EmailOptions> emailOptions, ILogger<AdminEmailController> logger)
|
||||
{
|
||||
_emailSender = emailSender;
|
||||
_emailOptions = emailOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("password-reset-template")]
|
||||
@@ -60,7 +62,18 @@ public class AdminEmailController : ControllerBase
|
||||
subject = EmailTemplateRenderer.Render(subject, values);
|
||||
body = EmailTemplateRenderer.Render(body, values);
|
||||
|
||||
await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken);
|
||||
try
|
||||
{
|
||||
await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Admin-only (RequirePermission oben) - die Exception-Message darf hier raus, sie enthält
|
||||
// keine SMTP-Zugangsdaten (nur MailKit-Fehlertext wie "Authentication failed"/"Connection
|
||||
// refused") und ist genau das, was zum Debuggen der Email:*-Konfiguration gebraucht wird.
|
||||
_logger.LogError(ex, "Test-Mail konnte nicht gesendet werden an {ToAddress}", request.ToAddress);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "send_failed", message = ex.Message });
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -13,12 +13,17 @@ namespace OmsorgCore.Api.Controllers;
|
||||
[Route("api/auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private const string RefreshTokenCookieName = "refreshToken";
|
||||
|
||||
private readonly IAuthService _authService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IUserService _userService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
private readonly PasswordPolicyOptions _passwordPolicyOptions;
|
||||
private readonly RefreshTokenOptions _refreshTokenOptions;
|
||||
private readonly IWebHostEnvironment _environment;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
IAuthService authService,
|
||||
@@ -26,7 +31,10 @@ public class AuthController : ControllerBase
|
||||
IPasswordResetService passwordResetService,
|
||||
IUserService userService,
|
||||
IDomainEventDispatcher dispatcher,
|
||||
IOptions<PasswordPolicyOptions> passwordPolicyOptions)
|
||||
IOptions<PasswordPolicyOptions> passwordPolicyOptions,
|
||||
IOptions<RefreshTokenOptions> refreshTokenOptions,
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_authService = authService;
|
||||
_currentUserService = currentUserService;
|
||||
@@ -34,6 +42,25 @@ public class AuthController : ControllerBase
|
||||
_userService = userService;
|
||||
_dispatcher = dispatcher;
|
||||
_passwordPolicyOptions = passwordPolicyOptions.Value;
|
||||
_refreshTokenOptions = refreshTokenOptions.Value;
|
||||
_environment = environment;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// HttpOnly, damit ein Browser-Frontend den Refresh-Token nie per JS lesen kann (XSS-Schutz) -
|
||||
// Path auf /api/auth eingeschränkt, da nur login/refresh/logout ihn brauchen. Secure nur außerhalb
|
||||
// von Development, weil der lokale Dev-Server per launchSettings.json standardmäßig nur über
|
||||
// http:// läuft (kein https-Profil default) - ein Secure-Cookie würde der Browser dort nie setzen.
|
||||
private void SetRefreshTokenCookie(string refreshToken)
|
||||
{
|
||||
Response.Cookies.Append(RefreshTokenCookieName, refreshToken, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = !_environment.IsDevelopment(),
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Expires = DateTimeOffset.UtcNow.AddDays(_refreshTokenOptions.ExpiryDays),
|
||||
Path = "/api/auth"
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
@@ -54,25 +81,37 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
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));
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
return Ok(new LoginResponse(result.Token, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(RefreshRequest request, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<LoginResponse>> Refresh(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _authService.RefreshAsync(request.RefreshToken, cancellationToken);
|
||||
if (!Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken) || string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _authService.RefreshAsync(refreshToken, cancellationToken);
|
||||
if (!result.Success || result.Token is null || result.RefreshToken is null || result.ExpiresAt is null)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
return Ok(new LoginResponse(result.Token, result.RefreshToken, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
SetRefreshTokenCookie(result.RefreshToken);
|
||||
return Ok(new LoginResponse(result.Token, result.ExpiresAt.Value, result.MustChangePassword));
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout(LogoutRequest request, CancellationToken cancellationToken)
|
||||
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
|
||||
{
|
||||
await _authService.RevokeAsync(request.RefreshToken, cancellationToken);
|
||||
if (Request.Cookies.TryGetValue(RefreshTokenCookieName, out var refreshToken) && !string.IsNullOrEmpty(refreshToken))
|
||||
{
|
||||
await _authService.RevokeAsync(refreshToken, cancellationToken);
|
||||
}
|
||||
|
||||
Response.Cookies.Delete(RefreshTokenCookieName, new CookieOptions { Path = "/api/auth" });
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(_currentUserService.UserId, _currentUserService.Username, _currentUserService.IpAddress, "Logout"),
|
||||
cancellationToken);
|
||||
@@ -95,7 +134,7 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
var permissions = profile.Permissions
|
||||
.Select(p => new PermissionDto(p.Module, p.Action))
|
||||
.Select(p => new PermissionDto(p.Module, p.Action, p.Scope))
|
||||
.ToList();
|
||||
|
||||
return Ok(new MeResponse(
|
||||
@@ -142,8 +181,22 @@ public class AuthController : ControllerBase
|
||||
var result = await _passwordResetService.RequestResetAsync(request.Username, cancellationToken);
|
||||
if (result.Status == PasswordResetRequestStatus.Sent && result.Email is not null && result.RawPin is not null)
|
||||
{
|
||||
await _dispatcher.DispatchAsync(
|
||||
new PasswordResetRequestedEvent(result.Email, result.RawPin), cancellationToken);
|
||||
try
|
||||
{
|
||||
await _dispatcher.DispatchAsync(
|
||||
new PasswordResetRequestedEvent(result.Email, result.RawPin), cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Der Reset-Code wurde bereits in der DB angelegt (PasswordResetService.RequestResetAsync) -
|
||||
// nur der E-Mail-Versand ist fehlgeschlagen (z.B. SMTP nicht erreichbar/falsch konfiguriert).
|
||||
// Client bekommt "email_unavailable" statt "sent", damit die UI ehrlich anzeigt, dass gerade
|
||||
// kein Code angekommen ist, statt den Nutzer auf einen leeren PIN-Eingabe-Schritt zu schicken.
|
||||
// Kein zusätzliches Enumeration-Risiko ggü. heute: "sent" vs. "cannot_reset" unterscheidet
|
||||
// bereits, ob der Username existiert (siehe ForgotPasswordRequestResponse-Doku).
|
||||
_logger.LogError(ex, "Passwort-Reset-E-Mail konnte nicht versendet werden für UserId {UserId}", result.UserId);
|
||||
return Ok(new ForgotPasswordRequestResponse("email_unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
var status = result.Status == PasswordResetRequestStatus.Sent ? "sent" : "cannot_reset";
|
||||
|
||||
@@ -151,6 +151,14 @@ public class ContractsController : ControllerBase
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _contractService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFieldsAsync(
|
||||
string contractType,
|
||||
Guid? employeeId,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
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/documents")]
|
||||
public class DocumentsController : ControllerBase
|
||||
{
|
||||
private readonly IDocumentService _documentService;
|
||||
private readonly IEmployeeRepository _employeeRepository;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IDomainEventDispatcher _dispatcher;
|
||||
|
||||
public DocumentsController(
|
||||
IDocumentService documentService,
|
||||
IEmployeeRepository employeeRepository,
|
||||
ICurrentUserService currentUserService,
|
||||
IDomainEventDispatcher dispatcher)
|
||||
{
|
||||
_documentService = documentService;
|
||||
_employeeRepository = employeeRepository;
|
||||
_currentUserService = currentUserService;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<DocumentResponse>>> GetByEntity(
|
||||
[FromQuery] string entityType,
|
||||
[FromQuery] Guid entityId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.TryParse<DocumentEntityType>(entityType, out _))
|
||||
{
|
||||
return BadRequest($"entityType muss einer der folgenden Werte sein: {string.Join(", ", Enum.GetNames<DocumentEntityType>())}.");
|
||||
}
|
||||
|
||||
var documents = await _documentService.GetByEntityAsync(entityType, entityId, cancellationToken);
|
||||
return Ok(documents.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.Create)]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<ActionResult<DocumentResponse>> Upload([FromForm] UploadDocumentRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Enum.TryParse<DocumentEntityType>(request.EntityType, out var entityType))
|
||||
{
|
||||
return BadRequest($"EntityType muss einer der folgenden Werte sein: {string.Join(", ", Enum.GetNames<DocumentEntityType>())}.");
|
||||
}
|
||||
|
||||
if (entityType == DocumentEntityType.Employee)
|
||||
{
|
||||
var employee = await _employeeRepository.GetByIdAsync(request.EntityId, cancellationToken);
|
||||
if (employee is null)
|
||||
{
|
||||
return BadRequest("EntityId verweist auf keinen existierenden Mitarbeiter.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return BadRequest("Dokumente sind aktuell nur für EntityType=Employee möglich.");
|
||||
}
|
||||
|
||||
if (request.File is null || request.File.Length == 0)
|
||||
{
|
||||
return BadRequest("File ist erforderlich.");
|
||||
}
|
||||
|
||||
await using var stream = request.File.OpenReadStream();
|
||||
var result = await _documentService.UploadAsync(
|
||||
request.EntityType,
|
||||
request.EntityId,
|
||||
request.Category,
|
||||
request.Description,
|
||||
request.File.FileName,
|
||||
request.File.ContentType,
|
||||
request.File.Length,
|
||||
stream,
|
||||
_currentUserService.UserId!.Value,
|
||||
cancellationToken);
|
||||
|
||||
if (result.Error != DocumentUploadError.None || result.Document is null)
|
||||
{
|
||||
return BadRequest(ToErrorMessage(result.Error));
|
||||
}
|
||||
|
||||
var created = await _documentService.GetByIdAsync(result.Document.Id, cancellationToken) ?? result.Document;
|
||||
return CreatedAtAction(nameof(GetByEntity), new { entityType = created.EntityType, entityId = created.EntityId }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<DocumentResponse>> Update(Guid id, UpdateDocumentRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.FileName) || request.FileName.Length > 260)
|
||||
{
|
||||
return BadRequest("FileName ist erforderlich und darf maximal 260 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var result = await _documentService.UpdateAsync(id, request.Category, request.Description, request.FileName, cancellationToken);
|
||||
|
||||
return result.Error switch
|
||||
{
|
||||
DocumentUpdateError.NotFound => NotFound(),
|
||||
DocumentUpdateError.InvalidCategory => BadRequest("Category ist ungültig."),
|
||||
_ => Ok(ToResponse(result.Document!))
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/download")]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.View)]
|
||||
public async Task<IActionResult> Download(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var document = await _documentService.GetByIdAsync(id, cancellationToken);
|
||||
if (document is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var stream = await _documentService.OpenForDownloadAsync(id, cancellationToken);
|
||||
if (stream is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
await _dispatcher.DispatchAsync(
|
||||
new AuditEvent(
|
||||
_currentUserService.UserId,
|
||||
_currentUserService.Username,
|
||||
_currentUserService.IpAddress,
|
||||
"DocumentDownloaded",
|
||||
$"{{\"documentId\":\"{document.Id}\",\"fileName\":\"{document.FileName}\"}}"),
|
||||
cancellationToken);
|
||||
|
||||
return File(stream, document.ContentType, document.FileName);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Documents, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _documentService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private static string ToErrorMessage(DocumentUploadError error) => error switch
|
||||
{
|
||||
DocumentUploadError.InvalidCategory => "Category ist ungültig.",
|
||||
DocumentUploadError.FileTooLarge => "Die Datei überschreitet die maximal erlaubte Größe.",
|
||||
DocumentUploadError.ContentTypeNotAllowed => "Dieser Dateityp ist nicht erlaubt.",
|
||||
_ => "Upload fehlgeschlagen."
|
||||
};
|
||||
|
||||
private static DocumentResponse ToResponse(Document document)
|
||||
=> new(
|
||||
document.Id,
|
||||
document.EntityType,
|
||||
document.EntityId,
|
||||
document.Category,
|
||||
document.FileName,
|
||||
document.ContentType,
|
||||
document.SizeBytes,
|
||||
document.Description,
|
||||
document.UploadedByUserId,
|
||||
document.UploadedByUser?.Username,
|
||||
document.CreatedAt);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class EmployeesController : ControllerBase
|
||||
{
|
||||
private const string StatusListKey = "EmployeeStatus";
|
||||
private const string EmploymentTypeListKey = "EmploymentType";
|
||||
private const string QualificationListKey = "Qualification";
|
||||
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
@@ -220,6 +221,14 @@ public class EmployeesController : ControllerBase
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _employeeService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country)
|
||||
{
|
||||
if (street is { Length: > 200 })
|
||||
@@ -277,9 +286,13 @@ public class EmployeesController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
if (qualification is { Length: > 500 })
|
||||
if (qualification is not null)
|
||||
{
|
||||
return "Qualification darf maximal 500 Zeichen lang sein.";
|
||||
var allowedQualifications = await _valueListRepository.GetActiveValuesAsync(QualificationListKey, cancellationToken);
|
||||
if (!allowedQualifications.Contains(qualification))
|
||||
{
|
||||
return $"Qualification muss einer der folgenden Werte sein: {string.Join(", ", allowedQualifications)}.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -17,6 +17,8 @@ public class FacilitiesController : ControllerBase
|
||||
{
|
||||
private const string CrmStatusListKey = "CrmStatus";
|
||||
private const string FacilityTypeListKey = "FacilityType";
|
||||
private const string FollowUpPeriodsListKey = "FollowUpPeriods";
|
||||
private const string BillingIntervalListKey = "BillingInterval";
|
||||
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
@@ -34,6 +36,7 @@ public class FacilitiesController : ControllerBase
|
||||
public async Task<ActionResult<PagedResponse<FacilityResponse>>> GetAll(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? crmStatus,
|
||||
[FromQuery] bool followUpDueOnly = false,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -41,7 +44,7 @@ public class FacilitiesController : ControllerBase
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _facilityService.GetPagedAsync(search, crmStatus, page, pageSize, cancellationToken);
|
||||
var (items, totalCount) = await _facilityService.GetPagedAsync(search, crmStatus, followUpDueOnly, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<FacilityResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
@@ -118,6 +121,12 @@ public class FacilitiesController : ControllerBase
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<FacilityResponse>> Update(Guid id, UpdateFacilityRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 300)
|
||||
{
|
||||
return BadRequest("Name ist erforderlich und darf maximal 300 Zeichen lang sein.");
|
||||
@@ -128,10 +137,37 @@ public class FacilitiesController : ControllerBase
|
||||
return BadRequest("CrmStatus ist erforderlich und darf maximal 50 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var allowedCrmStatuses = await _valueListRepository.GetActiveValuesAsync(CrmStatusListKey, cancellationToken);
|
||||
if (!allowedCrmStatuses.Contains(request.CrmStatus))
|
||||
var crmStatusItems = await _valueListRepository.GetItemsAsync(CrmStatusListKey, cancellationToken);
|
||||
var selectedCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == request.CrmStatus);
|
||||
if (selectedCrmStatusItem is null)
|
||||
{
|
||||
return BadRequest($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", allowedCrmStatuses)}.");
|
||||
return BadRequest($"CrmStatus muss einer der folgenden Werte sein: {string.Join(", ", crmStatusItems.Select(i => i.Value))}.");
|
||||
}
|
||||
|
||||
var currentCrmStatusItem = crmStatusItems.FirstOrDefault(i => i.Value == existing.CrmStatus);
|
||||
if (currentCrmStatusItem is not null
|
||||
&& !await _valueListRepository.CanTransitionAsync(currentCrmStatusItem.Id, selectedCrmStatusItem.Id, cancellationToken))
|
||||
{
|
||||
return BadRequest("Der Statuswechsel ist nicht zulässig.");
|
||||
}
|
||||
|
||||
DateTime? followUpDueDate = null;
|
||||
if (selectedCrmStatusItem.TriggersFollowUp)
|
||||
{
|
||||
if (existing.CrmStatus == request.CrmStatus)
|
||||
{
|
||||
followUpDueDate = existing.FollowUpDueDate;
|
||||
}
|
||||
else
|
||||
{
|
||||
var allowedFollowUpPeriods = await _valueListRepository.GetActiveValuesAsync(FollowUpPeriodsListKey, cancellationToken);
|
||||
if (request.FollowUpDays is null || !allowedFollowUpPeriods.Contains(request.FollowUpDays.Value.ToString()))
|
||||
{
|
||||
return BadRequest($"FollowUpDays ist bei CrmStatus \"{request.CrmStatus}\" erforderlich und muss einer der folgenden Werte sein: {string.Join(", ", allowedFollowUpPeriods)}.");
|
||||
}
|
||||
|
||||
followUpDueDate = DateTime.UtcNow.AddDays(request.FollowUpDays.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (request.FacilityType is { Length: > 100 })
|
||||
@@ -165,10 +201,53 @@ public class FacilitiesController : ControllerBase
|
||||
return BadRequest(billingAddressError);
|
||||
}
|
||||
|
||||
if (request.BreakPolicy is { Length: > 1000 })
|
||||
{
|
||||
return BadRequest("BreakPolicy darf maximal 1000 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.IndividualAgreements is { Length: > 2000 })
|
||||
{
|
||||
return BadRequest("IndividualAgreements darf maximal 2000 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
if (request.BillingInterval is not null)
|
||||
{
|
||||
var allowedBillingIntervals = await _valueListRepository.GetActiveValuesAsync(BillingIntervalListKey, cancellationToken);
|
||||
if (!allowedBillingIntervals.Contains(request.BillingInterval))
|
||||
{
|
||||
return BadRequest($"BillingInterval muss einer der folgenden Werte sein: {string.Join(", ", allowedBillingIntervals)}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (request.BillingRate is < 0
|
||||
|| request.TravelCostRate is < 0
|
||||
|| request.MinimumHours is < 0
|
||||
|| request.NightSurchargePercent is < 0
|
||||
|| request.SaturdaySurchargePercent is < 0
|
||||
|| request.SundaySurchargePercent is < 0
|
||||
|| request.HolidaySurchargePercent is < 0
|
||||
|| request.PaymentTermDays is < 0)
|
||||
{
|
||||
return BadRequest("Konditionswerte dürfen nicht negativ sein.");
|
||||
}
|
||||
|
||||
var updates = new Facility
|
||||
{
|
||||
Name = request.Name,
|
||||
CrmStatus = request.CrmStatus,
|
||||
FollowUpDueDate = followUpDueDate,
|
||||
BillingRate = request.BillingRate,
|
||||
NightSurchargePercent = request.NightSurchargePercent,
|
||||
SaturdaySurchargePercent = request.SaturdaySurchargePercent,
|
||||
SundaySurchargePercent = request.SundaySurchargePercent,
|
||||
HolidaySurchargePercent = request.HolidaySurchargePercent,
|
||||
TravelCostRate = request.TravelCostRate,
|
||||
MinimumHours = request.MinimumHours,
|
||||
BreakPolicy = request.BreakPolicy,
|
||||
BillingInterval = request.BillingInterval,
|
||||
PaymentTermDays = request.PaymentTermDays,
|
||||
IndividualAgreements = request.IndividualAgreements,
|
||||
FacilityType = request.FacilityType,
|
||||
Website = request.Website,
|
||||
Street = request.Street,
|
||||
@@ -185,6 +264,14 @@ public class FacilitiesController : ControllerBase
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _facilityService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country, string prefix)
|
||||
{
|
||||
if (street is { Length: > 200 })
|
||||
@@ -224,5 +311,17 @@ public class FacilitiesController : ControllerBase
|
||||
facility.BillingStreet,
|
||||
facility.BillingPostalCode,
|
||||
facility.BillingCity,
|
||||
facility.BillingCountry);
|
||||
facility.BillingCountry,
|
||||
facility.FollowUpDueDate,
|
||||
facility.BillingRate,
|
||||
facility.NightSurchargePercent,
|
||||
facility.SaturdaySurchargePercent,
|
||||
facility.SundaySurchargePercent,
|
||||
facility.HolidaySurchargePercent,
|
||||
facility.TravelCostRate,
|
||||
facility.MinimumHours,
|
||||
facility.BreakPolicy,
|
||||
facility.BillingInterval,
|
||||
facility.PaymentTermDays,
|
||||
facility.IndividualAgreements);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,20 @@ public class FacilityContactsController : ControllerBase
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid facilityId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityContactService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null || existing.FacilityId != facilityId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var deleted = await _facilityContactService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private static string? ValidateRequest(string name, string? role, string? department, string? phoneNumber, string? email, string? notes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) || name.Length > 200)
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
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;
|
||||
|
||||
namespace OmsorgCore.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Qualifikationsabhängige Verrechnungssätze sind eine 1:n-Unterressource von Facility (FR-EIN-4) —
|
||||
/// kein eigenständiges Core-Objekt, daher unter /api/facilities/{facilityId}/qualification-rates und
|
||||
/// mit den gleichen Facilities-Rechten gegated statt einem eigenen ModuleType (analog FacilityContact).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/facilities/{facilityId:guid}/qualification-rates")]
|
||||
public class FacilityQualificationRatesController : ControllerBase
|
||||
{
|
||||
private const string QualificationListKey = "Qualification";
|
||||
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IFacilityQualificationRateService _facilityQualificationRateService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public FacilityQualificationRatesController(
|
||||
IFacilityService facilityService,
|
||||
IFacilityQualificationRateService facilityQualificationRateService,
|
||||
IValueListRepository valueListRepository)
|
||||
{
|
||||
_facilityService = facilityService;
|
||||
_facilityQualificationRateService = facilityQualificationRateService;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<FacilityQualificationRateResponse>>> GetAll(Guid facilityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var rates = await _facilityQualificationRateService.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
return Ok(rates.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Create)]
|
||||
public async Task<ActionResult<FacilityQualificationRateResponse>> Create(Guid facilityId, CreateFacilityQualificationRateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await _facilityService.GetByIdAsync(facilityId, cancellationToken) is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var fieldError = await ValidateRequestAsync(request.Qualification, request.Rate, cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var rate = new FacilityQualificationRate
|
||||
{
|
||||
FacilityId = facilityId,
|
||||
Qualification = request.Qualification,
|
||||
Rate = request.Rate
|
||||
};
|
||||
|
||||
var created = await _facilityQualificationRateService.CreateAsync(rate, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetAll), new { facilityId }, ToResponse(created));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<FacilityQualificationRateResponse>> Update(Guid facilityId, Guid id, UpdateFacilityQualificationRateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityQualificationRateService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null || existing.FacilityId != facilityId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var fieldError = await ValidateRequestAsync(request.Qualification, request.Rate, cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var updates = new FacilityQualificationRate
|
||||
{
|
||||
Qualification = request.Qualification,
|
||||
Rate = request.Rate
|
||||
};
|
||||
|
||||
var updated = await _facilityQualificationRateService.UpdateAsync(id, updates, cancellationToken);
|
||||
return updated is null ? NotFound() : Ok(ToResponse(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid facilityId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await _facilityQualificationRateService.GetByIdAsync(id, cancellationToken);
|
||||
if (existing is null || existing.FacilityId != facilityId)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var deleted = await _facilityQualificationRateService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateRequestAsync(string qualification, decimal rate, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(qualification) || qualification.Length > 200)
|
||||
{
|
||||
return "Qualification ist erforderlich und darf maximal 200 Zeichen lang sein.";
|
||||
}
|
||||
|
||||
var allowedQualifications = await _valueListRepository.GetActiveValuesAsync(QualificationListKey, cancellationToken);
|
||||
if (!allowedQualifications.Contains(qualification))
|
||||
{
|
||||
return $"Qualification muss einer der folgenden Werte sein: {string.Join(", ", allowedQualifications)}.";
|
||||
}
|
||||
|
||||
if (rate < 0)
|
||||
{
|
||||
return "Rate darf nicht negativ sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static FacilityQualificationRateResponse ToResponse(FacilityQualificationRate rate)
|
||||
=> new(
|
||||
rate.Id,
|
||||
rate.FacilityId,
|
||||
rate.Qualification,
|
||||
rate.Rate);
|
||||
}
|
||||
@@ -15,6 +15,9 @@ namespace OmsorgCore.Api.Controllers;
|
||||
public class OrdersController : ControllerBase
|
||||
{
|
||||
private const string StatusListKey = "OrderStatus";
|
||||
private const string QualificationListKey = "Qualification";
|
||||
private const string ShiftTypeListKey = "ShiftType";
|
||||
private const string PriorityListKey = "Priority";
|
||||
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IFacilityContactService _facilityContactService;
|
||||
@@ -39,6 +42,9 @@ public class OrdersController : ControllerBase
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] Guid? statusId,
|
||||
[FromQuery] Guid? facilityId,
|
||||
[FromQuery] string? priority,
|
||||
[FromQuery] string? requiredQualification,
|
||||
[FromQuery] string? shiftType,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -46,7 +52,8 @@ public class OrdersController : ControllerBase
|
||||
page = Math.Max(page, 1);
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
|
||||
var (items, totalCount) = await _orderService.GetPagedAsync(search, statusId, facilityId, page, pageSize, cancellationToken);
|
||||
var (items, totalCount) = await _orderService.GetPagedAsync(
|
||||
search, statusId, facilityId, priority, requiredQualification, shiftType, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<OrderResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
@@ -62,7 +69,7 @@ public class OrdersController : ControllerBase
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Create)]
|
||||
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(
|
||||
var fieldError = await ValidateFieldsAsync(
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
@@ -70,7 +77,8 @@ public class OrdersController : ControllerBase
|
||||
request.ShiftType,
|
||||
request.RequiredHeadcount,
|
||||
request.Conditions,
|
||||
request.Priority);
|
||||
request.Priority,
|
||||
cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
@@ -106,7 +114,7 @@ public class OrdersController : ControllerBase
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<OrderResponse>> Update(Guid id, UpdateOrderRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(
|
||||
var fieldError = await ValidateFieldsAsync(
|
||||
request.FacilityId,
|
||||
request.StartDate,
|
||||
request.EndDate,
|
||||
@@ -114,7 +122,8 @@ public class OrdersController : ControllerBase
|
||||
request.ShiftType,
|
||||
request.RequiredHeadcount,
|
||||
request.Conditions,
|
||||
request.Priority);
|
||||
request.Priority,
|
||||
cancellationToken);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
@@ -160,6 +169,14 @@ public class OrdersController : ControllerBase
|
||||
return Ok(ToResponse(result.Order!));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _orderService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
private async Task<string?> ValidateFacilityContactAsync(Guid? facilityContactId, Guid facilityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (facilityContactId is null)
|
||||
@@ -176,7 +193,7 @@ public class OrdersController : ControllerBase
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateFields(
|
||||
private async Task<string?> ValidateFieldsAsync(
|
||||
Guid facilityId,
|
||||
DateOnly startDate,
|
||||
DateOnly? endDate,
|
||||
@@ -184,7 +201,8 @@ public class OrdersController : ControllerBase
|
||||
string? shiftType,
|
||||
int requiredHeadcount,
|
||||
string? conditions,
|
||||
string priority)
|
||||
string priority,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (facilityId == Guid.Empty)
|
||||
{
|
||||
@@ -206,19 +224,33 @@ public class OrdersController : ControllerBase
|
||||
return "RequiredHeadcount muss mindestens 1 sein.";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(priority) || priority.Length > 50)
|
||||
if (string.IsNullOrWhiteSpace(priority))
|
||||
{
|
||||
return "Priority ist erforderlich und darf maximal 50 Zeichen lang sein.";
|
||||
return "Priority ist erforderlich.";
|
||||
}
|
||||
|
||||
if (requiredQualification is { Length: > 200 })
|
||||
var priorityItems = await _valueListService.GetItemsAsync(PriorityListKey, cancellationToken);
|
||||
if (!priorityItems.Any(i => i.Value == priority))
|
||||
{
|
||||
return "RequiredQualification darf maximal 200 Zeichen lang sein.";
|
||||
return $"Priority muss einer der folgenden Werte sein: {string.Join(", ", priorityItems.Select(i => i.Value))}.";
|
||||
}
|
||||
|
||||
if (shiftType is { Length: > 100 })
|
||||
if (requiredQualification is not null)
|
||||
{
|
||||
return "ShiftType darf maximal 100 Zeichen lang sein.";
|
||||
var qualificationItems = await _valueListService.GetItemsAsync(QualificationListKey, cancellationToken);
|
||||
if (!qualificationItems.Any(i => i.Value == requiredQualification))
|
||||
{
|
||||
return $"RequiredQualification muss einer der folgenden Werte sein: {string.Join(", ", qualificationItems.Select(i => i.Value))}.";
|
||||
}
|
||||
}
|
||||
|
||||
if (shiftType is not null)
|
||||
{
|
||||
var shiftTypeItems = await _valueListService.GetItemsAsync(ShiftTypeListKey, cancellationToken);
|
||||
if (!shiftTypeItems.Any(i => i.Value == shiftType))
|
||||
{
|
||||
return $"ShiftType muss einer der folgenden Werte sein: {string.Join(", ", shiftTypeItems.Select(i => i.Value))}.";
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions is { Length: > 500 })
|
||||
|
||||
@@ -60,7 +60,7 @@ public class RolesController : ControllerBase
|
||||
}
|
||||
|
||||
var permissions = role.RolePermissions
|
||||
.Select(rp => new PermissionDto(rp.Module, rp.Action))
|
||||
.Select(rp => new PermissionDto(rp.Module, rp.Action, rp.Scope))
|
||||
.ToList();
|
||||
|
||||
return Ok(new RolePermissionsResponse(role.Id, role.Name, permissions));
|
||||
@@ -70,7 +70,7 @@ public class RolesController : ControllerBase
|
||||
[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 parsed = request.Permissions.Select(dto => (dto.Module, dto.Action, dto.Scope)).ToList();
|
||||
|
||||
var result = await _roleService.UpdatePermissionsAsync(id, parsed, cancellationToken);
|
||||
if (!result.Success)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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>
|
||||
/// Strukturierte Zeiterfassung pro Schicht (FR-ZE-1) mit Statuspipeline
|
||||
/// Entwurf -> Eingereicht -> Prüfung -> Rückfrage -> Freigegeben -> Abgerechnet (FR-ZE-2). Außendienst
|
||||
/// darf nur Create/View/Edit mit PermissionScope.Own (eigene Einträge, EmployeeId wird serverseitig
|
||||
/// aus dem JWT gesetzt, siehe TimeEntryService.CreateAsync) und die Selbst-Einreichungs-Kante über
|
||||
/// <see cref="Submit"/> auslösen; Büro-Rollen entscheiden über <see cref="Decide"/> (Approve).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/time-entries")]
|
||||
public class TimeEntriesController : ControllerBase
|
||||
{
|
||||
private readonly ITimeEntryService _timeEntryService;
|
||||
|
||||
public TimeEntriesController(ITimeEntryService timeEntryService)
|
||||
{
|
||||
_timeEntryService = timeEntryService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.View)]
|
||||
public async Task<ActionResult<PagedResponse<TimeEntryResponse>>> GetAll(
|
||||
[FromQuery] Guid? statusId,
|
||||
[FromQuery] Guid? employeeId,
|
||||
[FromQuery] Guid? orderId,
|
||||
[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 _timeEntryService.GetPagedAsync(statusId, employeeId, orderId, page, pageSize, cancellationToken);
|
||||
return Ok(new PagedResponse<TimeEntryResponse>(items.Select(ToResponse).ToList(), totalCount, page, pageSize));
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.View)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> GetById(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var timeEntry = await _timeEntryService.GetByIdAsync(id, cancellationToken);
|
||||
return timeEntry is null ? NotFound() : Ok(ToResponse(timeEntry));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Create)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> Create(CreateTimeEntryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(request.Start, request.End, request.NightHours, request.SaturdayHours, request.SundayHours, request.HolidayHours);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var timeEntry = new TimeEntry
|
||||
{
|
||||
OrderId = request.OrderId,
|
||||
Date = request.Date,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
BreakDuration = request.BreakDuration,
|
||||
NightHours = request.NightHours,
|
||||
SaturdayHours = request.SaturdayHours,
|
||||
SundayHours = request.SundayHours,
|
||||
HolidayHours = request.HolidayHours
|
||||
};
|
||||
|
||||
var created = await _timeEntryService.CreateAsync(timeEntry, cancellationToken);
|
||||
if (created is null)
|
||||
{
|
||||
return BadRequest("Zeiterfassung konnte nicht angelegt werden - entweder kein verknüpfter Mitarbeiter oder der Auftrag existiert nicht.");
|
||||
}
|
||||
|
||||
var reloaded = await _timeEntryService.GetByIdAsync(created.Id, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> Update(Guid id, UpdateTimeEntryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var fieldError = ValidateFields(request.Start, request.End, request.NightHours, request.SaturdayHours, request.SundayHours, request.HolidayHours);
|
||||
if (fieldError is not null)
|
||||
{
|
||||
return BadRequest(fieldError);
|
||||
}
|
||||
|
||||
var updates = new TimeEntry
|
||||
{
|
||||
OrderId = request.OrderId,
|
||||
Date = request.Date,
|
||||
Start = request.Start,
|
||||
End = request.End,
|
||||
BreakDuration = request.BreakDuration,
|
||||
NightHours = request.NightHours,
|
||||
SaturdayHours = request.SaturdayHours,
|
||||
SundayHours = request.SundayHours,
|
||||
HolidayHours = request.HolidayHours
|
||||
};
|
||||
|
||||
var result = await _timeEntryService.UpdateAsync(id, updates, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
{
|
||||
UpdateTimeEntryFailureReason.NotEditable => BadRequest("Die Zeiterfassung wurde bereits zur Prüfung übergeben und kann nicht mehr bearbeitet werden."),
|
||||
UpdateTimeEntryFailureReason.OrderNotFound => BadRequest("Der angegebene Auftrag existiert nicht."),
|
||||
_ => NotFound()
|
||||
};
|
||||
}
|
||||
|
||||
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||
return Ok(ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/submit")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> Submit(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _timeEntryService.SubmitAsync(id, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason == SubmitTimeEntryFailureReason.NoSelfServiceTransition
|
||||
? BadRequest("Aus dem aktuellen Status ist keine Einreichung möglich.")
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||
return Ok(ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/decision")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Approve)]
|
||||
public async Task<ActionResult<TimeEntryResponse>> Decide(Guid id, TimeEntryDecisionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.AdminNote is { Length: > 500 })
|
||||
{
|
||||
return BadRequest("AdminNote darf maximal 500 Zeichen lang sein.");
|
||||
}
|
||||
|
||||
var result = await _timeEntryService.DecideAsync(id, request.StatusId, request.AdminNote, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason == DecideTimeEntryFailureReason.InvalidStatusTransition
|
||||
? BadRequest("Der Statuswechsel ist nicht zulässig.")
|
||||
: NotFound();
|
||||
}
|
||||
|
||||
var reloaded = await _timeEntryService.GetByIdAsync(result.TimeEntry!.Id, cancellationToken);
|
||||
return Ok(ToResponse(reloaded!));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Delete)]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var deleted = await _timeEntryService.DeleteAsync(id, cancellationToken);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
// End < Start ist bewusst erlaubt (Nachtschichten, die über Mitternacht gehen) - keine
|
||||
// Start/Ende-Reihenfolge-Prüfung wie bei Absence.StartDate/EndDate.
|
||||
private static string? ValidateFields(TimeOnly start, TimeOnly end, decimal nightHours, decimal saturdayHours, decimal sundayHours, decimal holidayHours)
|
||||
{
|
||||
if (nightHours < 0 || saturdayHours < 0 || sundayHours < 0 || holidayHours < 0)
|
||||
{
|
||||
return "Zuschlagsstunden dürfen nicht negativ sein.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TimeEntryResponse ToResponse(TimeEntry timeEntry)
|
||||
=> new(
|
||||
timeEntry.Id,
|
||||
timeEntry.EmployeeId,
|
||||
timeEntry.Employee is null ? string.Empty : $"{timeEntry.Employee.FirstName} {timeEntry.Employee.LastName}",
|
||||
timeEntry.OrderId,
|
||||
timeEntry.Order?.FacilityId ?? Guid.Empty,
|
||||
timeEntry.Order?.Facility?.Name ?? string.Empty,
|
||||
timeEntry.Date,
|
||||
timeEntry.Start,
|
||||
timeEntry.End,
|
||||
timeEntry.BreakDuration,
|
||||
timeEntry.NightHours,
|
||||
timeEntry.SaturdayHours,
|
||||
timeEntry.SundayHours,
|
||||
timeEntry.HolidayHours,
|
||||
timeEntry.StatusId,
|
||||
timeEntry.Status?.Value ?? string.Empty,
|
||||
timeEntry.Status?.IsEditableByOwner ?? false,
|
||||
timeEntry.AdminNote,
|
||||
timeEntry.CreatedAt);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
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>
|
||||
/// Papierkorb: listet und stellt soft-gelöschte Datensätze der 5 Core-Objekte mit vollem CRUD
|
||||
/// wieder her. Reine API-Gruppierung für die Papierkorb-Seite in omsorgapp — kein eigener
|
||||
/// ModuleType/eigenes Recht, jede Route ist über das Recht des jeweiligen Objekts gegated
|
||||
/// (z. B. ModuleType.Employees + PermissionAction.Recover), analog zu den bestehenden
|
||||
/// Delete-Endpoints in EmployeesController/FacilitiesController/etc.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/trash")]
|
||||
public class TrashController : ControllerBase
|
||||
{
|
||||
private readonly IEmployeeService _employeeService;
|
||||
private readonly IFacilityService _facilityService;
|
||||
private readonly IContractService _contractService;
|
||||
private readonly IOrderService _orderService;
|
||||
private readonly IFacilityContactService _facilityContactService;
|
||||
private readonly IFacilityQualificationRateService _facilityQualificationRateService;
|
||||
private readonly IAbsenceService _absenceService;
|
||||
private readonly ITimeEntryService _timeEntryService;
|
||||
|
||||
public TrashController(
|
||||
IEmployeeService employeeService,
|
||||
IFacilityService facilityService,
|
||||
IContractService contractService,
|
||||
IOrderService orderService,
|
||||
IFacilityContactService facilityContactService,
|
||||
IFacilityQualificationRateService facilityQualificationRateService,
|
||||
IAbsenceService absenceService,
|
||||
ITimeEntryService timeEntryService)
|
||||
{
|
||||
_employeeService = employeeService;
|
||||
_facilityService = facilityService;
|
||||
_contractService = contractService;
|
||||
_orderService = orderService;
|
||||
_facilityContactService = facilityContactService;
|
||||
_facilityQualificationRateService = facilityQualificationRateService;
|
||||
_absenceService = absenceService;
|
||||
_timeEntryService = timeEntryService;
|
||||
}
|
||||
|
||||
[HttpGet("employees")]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashEmployeeResponse>>> GetDeletedEmployees([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var employees = await _employeeService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(employees.Select(e => new TrashEmployeeResponse(e.Id, e.FirstName, e.LastName, e.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("employees/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Employees, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreEmployee(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _employeeService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("facilities")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashFacilityResponse>>> GetDeletedFacilities([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var facilities = await _facilityService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(facilities.Select(f => new TrashFacilityResponse(f.Id, f.Name, f.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("facilities/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreFacility(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _facilityService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("contracts")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashContractResponse>>> GetDeletedContracts([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var contracts = await _contractService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(contracts.Select(c => new TrashContractResponse(c.Id, c.ContractType, c.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("contracts/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Contracts, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreContract(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _contractService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("orders")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashOrderResponse>>> GetDeletedOrders([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var orders = await _orderService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(orders.Select(o => new TrashOrderResponse(o.Id, o.RequiredQualification, o.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("orders/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Orders, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreOrder(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _orderService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("facility-contacts")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashFacilityContactResponse>>> GetDeletedFacilityContacts([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var contacts = await _facilityContactService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(contacts.Select(c => new TrashFacilityContactResponse(c.Id, c.FacilityId, c.Name, c.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("facility-contacts/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreFacilityContact(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _facilityContactService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("facility-qualification-rates")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashFacilityQualificationRateResponse>>> GetDeletedFacilityQualificationRates([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var rates = await _facilityQualificationRateService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(rates.Select(r => new TrashFacilityQualificationRateResponse(r.Id, r.FacilityId, r.Qualification, r.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("facility-qualification-rates/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Facilities, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreFacilityQualificationRate(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _facilityQualificationRateService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("absences")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashAbsenceResponse>>> GetDeletedAbsences([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var absences = await _absenceService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(absences.Select(a => new TrashAbsenceResponse(a.Id, a.Type, a.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("absences/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.Absences, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreAbsence(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _absenceService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("time-entries")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Recover)]
|
||||
public async Task<ActionResult<IReadOnlyList<TrashTimeEntryResponse>>> GetDeletedTimeEntries([FromQuery] string? search, CancellationToken cancellationToken)
|
||||
{
|
||||
var timeEntries = await _timeEntryService.GetDeletedAsync(search, cancellationToken);
|
||||
return Ok(timeEntries.Select(t => new TrashTimeEntryResponse(t.Id, t.Date, t.DeletedAt)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("time-entries/{id:guid}/restore")]
|
||||
[RequirePermission(ModuleType.TimeEntries, PermissionAction.Recover)]
|
||||
public async Task<IActionResult> RestoreTimeEntry(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var restored = await _timeEntryService.RestoreAsync(id, cancellationToken);
|
||||
return restored ? NoContent() : NotFound();
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.View)]
|
||||
[RequirePermission(ModuleType.Users, PermissionAction.View)]
|
||||
public async Task<ActionResult<IReadOnlyList<UserResponse>>> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
var users = await _userService.GetAllAsync(cancellationToken);
|
||||
@@ -40,7 +40,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Create)]
|
||||
[RequirePermission(ModuleType.Users, PermissionAction.Create)]
|
||||
public async Task<ActionResult<UserResponse>> Create(CreateUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 100)
|
||||
@@ -86,7 +86,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reset-password")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Users, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> ResetPassword(Guid id, ResetUserPasswordRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword);
|
||||
@@ -120,7 +120,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Users, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateUserRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _userService.UpdateAsync(id, request.RoleId, request.IsActive, cancellationToken);
|
||||
@@ -177,7 +177,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
return Ok(overrides.Select(o => new UserPermissionOverrideResponse(
|
||||
o.Id, o.Module, o.Action, o.Effect)).ToList());
|
||||
o.Id, o.Module, o.Action, o.Effect, o.Scope)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/permission-overrides")]
|
||||
@@ -185,7 +185,7 @@ public class UsersController : ControllerBase
|
||||
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);
|
||||
var result = await _userService.AddPermissionOverrideAsync(id, request.Module, request.Action, request.Effect, request.Scope, cancellationToken);
|
||||
if (!result.Success)
|
||||
{
|
||||
return result.FailureReason switch
|
||||
@@ -196,7 +196,7 @@ public class UsersController : ControllerBase
|
||||
}
|
||||
|
||||
var o = result.Override!;
|
||||
return Ok(new UserPermissionOverrideResponse(o.Id, o.Module, o.Action, o.Effect));
|
||||
return Ok(new UserPermissionOverrideResponse(o.Id, o.Module, o.Action, o.Effect, o.Scope));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/permission-overrides/{overrideId:guid}")]
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace OmsorgCore.Api.Controllers;
|
||||
/// 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"/>).
|
||||
/// Formulare gehören zu unterschiedlichen Modulen), Schreiben ist eine eigene Admin-Funktion
|
||||
/// (<see cref="ModuleType.Configuration"/>), getrennt von der Benutzer-/Rechteverwaltung.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -42,7 +42,7 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{key}/items")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<ValueListItemResponse>> CreateItem(string key, CreateValueListItemRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Value) || request.Value.Length > 100)
|
||||
@@ -53,7 +53,7 @@ public class ValueListsController : ControllerBase
|
||||
try
|
||||
{
|
||||
var item = await _valueListService.CreateItemAsync(
|
||||
key, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, cancellationToken);
|
||||
key, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, request.TriggersFollowUp, cancellationToken);
|
||||
return Ok(ToResponse(item));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
@@ -63,7 +63,7 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("{key}/items/{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, 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)
|
||||
@@ -72,12 +72,12 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
var item = await _valueListService.UpdateItemAsync(
|
||||
id, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, cancellationToken);
|
||||
id, request.Value, request.SortOrder, request.IsDefault, request.IsInitial, request.IsTerminal, request.TriggersFollowUp, cancellationToken);
|
||||
return item is null ? NotFound() : Ok(ToResponse(item));
|
||||
}
|
||||
|
||||
[HttpDelete("{key}/items/{id:guid}")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
|
||||
public async Task<IActionResult> DeleteItem(string key, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _valueListService.DeleteItemAsync(id, cancellationToken);
|
||||
@@ -97,7 +97,7 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpGet("{key}/items/{id:guid}/usages")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, PermissionAction.Edit)]
|
||||
public async Task<ActionResult<IReadOnlyList<ValueListUsageResponse>>> GetUsages(string key, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var usages = await _valueListService.GetUsagesAsync(id, cancellationToken);
|
||||
@@ -108,11 +108,11 @@ public class ValueListsController : ControllerBase
|
||||
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());
|
||||
return Ok(transitions.Select(t => new ValueListTransitionResponse(t.Id, t.FromItemId, t.ToItemId, t.RequiresApproval)).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("{key}/transitions")]
|
||||
[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]
|
||||
[RequirePermission(ModuleType.Configuration, 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);
|
||||
@@ -120,5 +120,5 @@ public class ValueListsController : ControllerBase
|
||||
}
|
||||
|
||||
private static ValueListItemResponse ToResponse(ValueListItem item)
|
||||
=> new(item.Id, item.Value, item.SortOrder, item.IsDefault, item.IsInitial, item.IsTerminal);
|
||||
=> new(item.Id, item.Value, item.SortOrder, item.IsDefault, item.IsInitial, item.IsTerminal, item.TriggersFollowUp);
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ builder.Services.AddSwaggerGen(options =>
|
||||
// Schichten verdrahten: Domain kennt niemanden, Application kennt nur Domain,
|
||||
// Infrastructure implementiert Application-Interfaces, Engine ist die Event-Schicht
|
||||
// auf denselben Daten, Api verdrahtet alles nur hier.
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Services.AddEngine();
|
||||
@@ -58,6 +59,18 @@ builder.Services.AddEmail(builder.Configuration);
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||
|
||||
// Nötig, seit omsorgapp als Browser-SPA statt Electron läuft: der Refresh-Token geht per
|
||||
// HttpOnly-Cookie, dafür muss der Browser die Cross-Origin-Antwort mit Credentials akzeptieren.
|
||||
var corsOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddPolicy("Frontend", policy => policy
|
||||
.WithOrigins(corsOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowCredentials());
|
||||
});
|
||||
|
||||
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
|
||||
|
||||
builder.Services
|
||||
@@ -183,6 +196,8 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
app.UseCors("Frontend");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
|
||||
@@ -30,4 +30,13 @@ public class CurrentUserService : ICurrentUserService
|
||||
public string? RoleName => _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Role);
|
||||
|
||||
public string? IpAddress => _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||
|
||||
public Guid? EmployeeId
|
||||
{
|
||||
get
|
||||
{
|
||||
var value = _httpContextAccessor.HttpContext?.User.FindFirstValue("employeeId");
|
||||
return Guid.TryParse(value, out var id) ? id : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Cors": {
|
||||
"AllowedOrigins": []
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "OmsorgCore",
|
||||
"Audience": "OmsorgClients",
|
||||
@@ -21,6 +24,11 @@
|
||||
"MaxLoginFailures": 5,
|
||||
"LoginLockoutMinutes": 10
|
||||
},
|
||||
"Storage": {
|
||||
"DocumentsRootPath": "App_Data/documents",
|
||||
"MaxDocumentSizeBytes": 20971520,
|
||||
"AllowedDocumentContentTypes": "application/pdf,image/jpeg,image/png"
|
||||
},
|
||||
"Email": {
|
||||
"PinExpiryMinutes": 5,
|
||||
"MaxAttempts": 3,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IAbsenceRepository
|
||||
{
|
||||
Task<Absence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<Absence> Items, int TotalCount)> GetPagedAsync(
|
||||
string? status,
|
||||
string? type,
|
||||
Guid? employeeId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default,
|
||||
Guid? restrictToEmployeeId = null);
|
||||
Task AddAsync(Absence absence, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Absence absence, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Absence>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -16,5 +16,8 @@ public interface IContractRepository
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Contract contract, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Contract>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -12,4 +12,12 @@ public interface ICurrentUserService
|
||||
string? Username { get; }
|
||||
string? RoleName { get; }
|
||||
string? IpAddress { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Verknüpfte Mitarbeiter-Id (<see cref="Domain.Entities.User.EmployeeId"/>) dieses Users, falls
|
||||
/// vorhanden - aus dem JWT-Claim "employeeId", nicht per DB-Read. Wird nach Verknüpfen/Lösen
|
||||
/// erst mit dem nächsten Token-Refresh aktuell (Staleness-Fenster = Access-Token-Laufzeit).
|
||||
/// Anker für Own-Scope-Datenfilterung, siehe <see cref="IPermissionService.GetScopeAsync"/>.
|
||||
/// </summary>
|
||||
Guid? EmployeeId { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IDocumentRepository
|
||||
{
|
||||
Task<Document?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Document>> GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Document document, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Document>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Port für die physische Dateiablage eines <see cref="Domain.Entities.Document"/> — getrennt vom
|
||||
/// Repository (das nur Metadaten in der DB verwaltet), weil die Bytes bewusst NICHT als Blob in
|
||||
/// Postgres liegen, sondern auf dem Dateisystem (siehe omsorgCore/CLAUDE.md, Dokumentenarchiv).
|
||||
/// </summary>
|
||||
public interface IDocumentStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Schreibt den Inhalt auf die Storage und liefert den relativen <c>StorageKey</c>, unter dem
|
||||
/// er später wiedergefunden wird.
|
||||
/// </summary>
|
||||
Task<string> SaveAsync(string entityType, Guid entityId, Guid documentId, string originalFileName, Stream content, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Stream> OpenReadAsync(string storageKey, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Einzige Stelle, die entscheidet, ob ein Upload die konfigurierte Größen-/Dateityp-Grenze
|
||||
/// einhält (Storage:MaxDocumentSizeBytes/Storage:AllowedDocumentContentTypes) - analog zu
|
||||
/// <see cref="IPasswordPolicy"/>, damit die Regel nicht mehrfach im Controller/Service dupliziert wird.
|
||||
/// </summary>
|
||||
public interface IDocumentUploadPolicy
|
||||
{
|
||||
long MaxSizeBytes { get; }
|
||||
|
||||
bool IsSizeAllowed(long sizeBytes);
|
||||
bool IsContentTypeAllowed(string? contentType);
|
||||
}
|
||||
@@ -6,14 +6,22 @@ public interface IEmployeeRepository
|
||||
{
|
||||
Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
/// <param name="restrictToEmployeeId">
|
||||
/// Own-Scope-Filterung (siehe IPermissionService.GetScopeAsync): liefert bei Angabe nur den
|
||||
/// Datensatz mit dieser Id, unabhängig von <paramref name="search"/>/<paramref name="status"/>/<paramref name="employmentType"/>.
|
||||
/// </param>
|
||||
Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
string? employmentType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
CancellationToken cancellationToken = default,
|
||||
Guid? restrictToEmployeeId = null);
|
||||
Task AddAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Employee>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,8 @@ public interface IFacilityContactRepository
|
||||
Task<IReadOnlyList<FacilityContact>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(FacilityContact contact, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityContact>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface IFacilityQualificationRateRepository
|
||||
{
|
||||
Task<FacilityQualificationRate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityQualificationRate>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task AddAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityQualificationRate>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -9,10 +9,14 @@ public interface IFacilityRepository
|
||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
bool followUpDueOnly,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Facility facility, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Facility>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,16 @@ public interface IOrderRepository
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
string? priority,
|
||||
string? requiredQualification,
|
||||
string? shiftType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task AddAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(Order order, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Order>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@ public interface IPermissionService
|
||||
{
|
||||
Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Liefert den effektiven <see cref="PermissionScope"/> für Modul/Aktion dieses Users, oder
|
||||
/// <c>null</c> wenn gar nicht gewährt. Scope-unabhängig vom reinen Endpunkt-Gate
|
||||
/// (<see cref="HasPermissionAsync"/>) — wird von datenzugreifenden Application-Services
|
||||
/// konsultiert, um Own-Scope-Filterung anzuwenden (siehe REQUIREMENTS.md FR-MA-6).
|
||||
/// </summary>
|
||||
Task<PermissionScope?> GetScopeAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Alle tatsächlich gewährten Modul/Aktion-Kombinationen dieses Users (Rollen-Default + Overrides aufgelöst).</summary>
|
||||
Task<IReadOnlyList<PermissionGrant>> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Entfernt den gecachten Rechte-Stand dieses einzelnen Users - nach einem individuellen Override oder Rollenwechsel.</summary>
|
||||
void InvalidateUserPermissions(Guid userId);
|
||||
|
||||
/// <summary>Entfernt den gecachten Rechte-Stand aller User mit dieser Rolle - nach einer Änderung der Rollen-Rechte-Matrix.</summary>
|
||||
Task InvalidateRolePermissionsAsync(Guid roleId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Abstractions;
|
||||
|
||||
public interface ITimeEntryRepository
|
||||
{
|
||||
Task<TimeEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<(IReadOnlyList<TimeEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
Guid? statusId,
|
||||
Guid? employeeId,
|
||||
Guid? orderId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default,
|
||||
Guid? restrictToEmployeeId = null);
|
||||
Task AddAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default);
|
||||
Task<bool> SoftDeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<TimeEntry>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -6,9 +6,12 @@ public interface IUserRepository
|
||||
{
|
||||
Task<User?> GetByUsernameAsync(string username, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Inklusive Role+RolePermissions+PermissionOverrides (für die Rechteauflösung) und Employee (für /api/auth/me).</summary>
|
||||
/// <summary>Inklusive Role+RolePermissions+PermissionOverrides (für die Rechteauflösung) und Employee (für /api/auth/me). Getrackt - für Mutationsflows (Override hinzufügen/entfernen).</summary>
|
||||
Task<User?> GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Wie <see cref="GetByIdWithPermissionsAsync"/>, aber AsNoTracking - für PermissionService's Cache (nie mutiert, wird über Request-Grenzen hinweg gehalten).</summary>
|
||||
Task<User?> GetByIdWithPermissionsNoTrackingAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Schlanker Lookup ohne Includes - für den SecurityStamp-Check bei jedem Request.</summary>
|
||||
Task<User?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -17,6 +20,9 @@ public interface IUserRepository
|
||||
|
||||
Task<IReadOnlyList<User>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Nur die Ids der User mit dieser Rolle - für die Cache-Invalidierung bei Rollen-Rechte-Änderungen.</summary>
|
||||
Task<IReadOnlyList<Guid>> GetUserIdsByRoleAsync(Guid roleId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<User?> GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> ExistsByUsernameAsync(string username, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -19,6 +19,8 @@ public interface IValueListRepository
|
||||
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<ValueListItemTransition?> GetSelfServiceTransitionAsync(Guid fromItemId, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItemTransition?> GetTransitionAsync(Guid fromItemId, Guid toItemId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,12 @@ public static class DependencyInjection
|
||||
services.AddScoped<IEmployeeService, EmployeeService>();
|
||||
services.AddScoped<IFacilityService, FacilityService>();
|
||||
services.AddScoped<IFacilityContactService, FacilityContactService>();
|
||||
services.AddScoped<IFacilityQualificationRateService, FacilityQualificationRateService>();
|
||||
services.AddScoped<IContractService, ContractService>();
|
||||
services.AddScoped<IOrderService, OrderService>();
|
||||
services.AddScoped<IAbsenceService, AbsenceService>();
|
||||
services.AddScoped<ITimeEntryService, TimeEntryService>();
|
||||
services.AddScoped<IDocumentService, DocumentService>();
|
||||
services.AddScoped<IValueListService, ValueListService>();
|
||||
services.AddScoped<ISessionAdminService, SessionAdminService>();
|
||||
services.AddScoped<IPasswordResetService, PasswordResetService>();
|
||||
|
||||
@@ -3,4 +3,4 @@ using OmsorgCore.Domain.Enums;
|
||||
namespace OmsorgCore.Application.Models;
|
||||
|
||||
/// <summary>Eine für einen User tatsächlich gewährte Modul/Aktion-Kombination (Ergebnis der Rechte-Auflösung).</summary>
|
||||
public record PermissionGrant(ModuleType Module, PermissionAction Action);
|
||||
public record PermissionGrant(ModuleType Module, PermissionAction Action, PermissionScope Scope);
|
||||
|
||||
@@ -3,4 +3,4 @@ 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);
|
||||
public record PermissionOverrideSummary(Guid Id, ModuleType Module, PermissionAction Action, PermissionEffect Effect, PermissionScope Scope);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class AbsenceService : IAbsenceService
|
||||
{
|
||||
private const string StatusListKey = "AbsenceStatus";
|
||||
|
||||
private readonly IAbsenceRepository _absenceRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public AbsenceService(
|
||||
IAbsenceRepository absenceRepository,
|
||||
IPermissionService permissionService,
|
||||
ICurrentUserService currentUserService,
|
||||
IValueListRepository valueListRepository)
|
||||
{
|
||||
_absenceRepository = absenceRepository;
|
||||
_permissionService = permissionService;
|
||||
_currentUserService = currentUserService;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<Absence> Items, int TotalCount)> GetPagedAsync(
|
||||
string? status,
|
||||
string? type,
|
||||
Guid? employeeId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
return await _absenceRepository.GetPagedAsync(status, type, employeeId, page, pageSize, cancellationToken, restrictToEmployeeId);
|
||||
}
|
||||
|
||||
public async Task<Absence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var absence = await _absenceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (absence is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && absence.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return absence;
|
||||
}
|
||||
|
||||
public async Task<Absence?> CreateAsync(Absence absence, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Create, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue)
|
||||
{
|
||||
if (restrictToEmployeeId.Value == Guid.Empty)
|
||||
{
|
||||
// Own-Scope, aber kein User.EmployeeId verknüpft - fail-closed statt "für niemanden" anzulegen.
|
||||
return null;
|
||||
}
|
||||
|
||||
absence.EmployeeId = restrictToEmployeeId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
// All-Scope-Aufrufer (z. B. Büro-Rollen/Administrator): CreateAbsenceRequest hat bewusst
|
||||
// kein employeeId-Feld ("im Namen von" ist nicht Teil dieser ersten UI, siehe
|
||||
// omsorgCore/CLAUDE.md). Own-Scope ist nur eine Sichtbarkeits-/Anlege-Einschränkung
|
||||
// ("nur eigene Daten"), kein Ausschlusskriterium dafür, ob man überhaupt einen eigenen
|
||||
// Antrag stellen darf - auch Büro-Rollen sind Mitarbeiter und wollen eigenen Urlaub
|
||||
// beantragen können (Vorfall 2026-08-10: Admin mit verknüpftem Mitarbeiter bekam grundlos
|
||||
// 400). Deshalb hier Fallback auf die eigene verknüpfte Mitarbeiter-Id; nur wenn die
|
||||
// Aufrufer:in selbst gar keinen Mitarbeiter verknüpft hat, bleibt es ein harter Fehler.
|
||||
var ownEmployeeId = _currentUserService.EmployeeId;
|
||||
if (ownEmployeeId is null || ownEmployeeId == Guid.Empty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
absence.EmployeeId = ownEmployeeId.Value;
|
||||
}
|
||||
|
||||
// Nicht den Entity-Default hartkodiert übernehmen - der Wert kommt aus der admin-editierbaren
|
||||
// ValueList "AbsenceStatus" (IsInitial-Flag, von DbSeeder.SeedValueListsAsync gesetzt), damit
|
||||
// ein Umbenennen über die Status-Verwaltung nicht lautlos bricht (analog OrderService.CreateAsync).
|
||||
absence.Status = await GetInitialStatusValueAsync(cancellationToken);
|
||||
|
||||
await _absenceRepository.AddAsync(absence, cancellationToken);
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
return absence;
|
||||
}
|
||||
|
||||
public async Task<UpdateAbsenceResult> UpdateAsync(Guid id, Absence updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var absence = await _absenceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (absence is null)
|
||||
{
|
||||
return UpdateAbsenceResult.Fail(UpdateAbsenceFailureReason.NotFound);
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Edit, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && absence.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
// Wie GetByIdAsync: fremden Own-Scope-Antrag als "nicht gefunden" behandeln statt 403,
|
||||
// um nicht zu verraten, dass die Id überhaupt existiert.
|
||||
return UpdateAbsenceResult.Fail(UpdateAbsenceFailureReason.NotFound);
|
||||
}
|
||||
|
||||
if (absence.Status != await GetInitialStatusValueAsync(cancellationToken))
|
||||
{
|
||||
return UpdateAbsenceResult.Fail(UpdateAbsenceFailureReason.AlreadyDecided);
|
||||
}
|
||||
|
||||
absence.Type = updates.Type;
|
||||
absence.StartDate = updates.StartDate;
|
||||
absence.EndDate = updates.EndDate;
|
||||
absence.Reason = updates.Reason;
|
||||
absence.Substitute = updates.Substitute;
|
||||
absence.Note = updates.Note;
|
||||
absence.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _absenceRepository.UpdateAsync(absence, cancellationToken);
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateAbsenceResult.Ok(absence);
|
||||
}
|
||||
|
||||
public async Task<Absence?> DecideAsync(Guid id, string status, string? adminNote, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var absence = await _absenceRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (absence is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
absence.Status = status;
|
||||
absence.AdminNote = adminNote;
|
||||
absence.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _absenceRepository.UpdateAsync(absence, cancellationToken);
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
return absence;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _absenceRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Absence>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _absenceRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _absenceRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _absenceRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der aktuell als "initial"/pending markierte Status-Wert der ValueList "AbsenceStatus"
|
||||
/// (per Default "Eingereicht", aber nicht hartkodiert - über Status-Verwaltung umbenennbar,
|
||||
/// ohne dass diese Logik oder der Controller (Decide-Endpoint) mitgeändert werden müssten).
|
||||
/// </summary>
|
||||
public async Task<string> GetInitialStatusValueAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var initial = await _valueListRepository.GetInitialItemAsync(StatusListKey, cancellationToken);
|
||||
return initial?.Value
|
||||
?? throw new InvalidOperationException("Kein initialer Abwesenheitsstatus konfiguriert (DbSeeder.SeedValueListsAsync fehlt).");
|
||||
}
|
||||
|
||||
/// <summary>Own-Scope-Filterung, siehe EmployeeService.ResolveOwnScopeRestrictionAsync (analoges Muster für ModuleType.Absences).</summary>
|
||||
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(PermissionAction action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.Absences, action, cancellationToken);
|
||||
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class ContractService : IContractService
|
||||
{
|
||||
private readonly IContractRepository _contractRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public ContractService(IContractRepository contractRepository)
|
||||
public ContractService(
|
||||
IContractRepository contractRepository,
|
||||
IPermissionService permissionService,
|
||||
ICurrentUserService currentUserService)
|
||||
{
|
||||
_contractRepository = contractRepository;
|
||||
_permissionService = permissionService;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Contract>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
public async Task<(IReadOnlyList<Contract> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
Guid? employeeId,
|
||||
@@ -23,10 +31,43 @@ public class ContractService : IContractService
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetPagedAsync(search, status, employeeId, facilityId, page, pageSize, cancellationToken);
|
||||
{
|
||||
Guid? ownRestriction = await ResolveOwnScopeRestrictionAsync(cancellationToken);
|
||||
// Own-Scope erzwingt die eigene EmployeeId und überschreibt einen ggf. angeforderten
|
||||
// fremden employeeId-Filter - sonst könnte ein Own-User über den Query-Parameter fremde
|
||||
// Verträge abfragen.
|
||||
Guid? effectiveEmployeeId = ownRestriction ?? employeeId;
|
||||
return await _contractRepository.GetPagedAsync(search, status, effectiveEmployeeId, facilityId, page, pageSize, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetByIdAsync(id, cancellationToken);
|
||||
public async Task<Contract?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var contract = await _contractRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (contract is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? ownRestriction = await ResolveOwnScopeRestrictionAsync(cancellationToken);
|
||||
if (ownRestriction.HasValue && contract.EmployeeId != ownRestriction.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
/// <summary>Own-Scope-Filterung, siehe EmployeeService.ResolveOwnScopeRestrictionAsync (analoges Muster für ModuleType.Contracts).</summary>
|
||||
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.Contracts, PermissionAction.View, cancellationToken);
|
||||
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||
}
|
||||
|
||||
public async Task<Contract> CreateAsync(Contract contract, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -61,4 +102,29 @@ public class ContractService : IContractService
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
return contract;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _contractRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Contract>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _contractRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _contractRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _contractRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum DecideTimeEntryFailureReason
|
||||
{
|
||||
NotFound,
|
||||
InvalidStatusTransition
|
||||
}
|
||||
|
||||
public class DecideTimeEntryResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public DecideTimeEntryFailureReason? FailureReason { get; init; }
|
||||
public TimeEntry? TimeEntry { get; init; }
|
||||
|
||||
public static DecideTimeEntryResult Fail(DecideTimeEntryFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static DecideTimeEntryResult Ok(TimeEntry timeEntry) => new()
|
||||
{
|
||||
Success = true,
|
||||
TimeEntry = timeEntry
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class DocumentService : IDocumentService
|
||||
{
|
||||
private const string DocumentCategoryListKey = "DocumentCategory";
|
||||
|
||||
private readonly IDocumentRepository _documentRepository;
|
||||
private readonly IDocumentStorage _documentStorage;
|
||||
private readonly IDocumentUploadPolicy _uploadPolicy;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public DocumentService(
|
||||
IDocumentRepository documentRepository,
|
||||
IDocumentStorage documentStorage,
|
||||
IDocumentUploadPolicy uploadPolicy,
|
||||
IValueListRepository valueListRepository)
|
||||
{
|
||||
_documentRepository = documentRepository;
|
||||
_documentStorage = documentStorage;
|
||||
_uploadPolicy = uploadPolicy;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
public async Task<DocumentUploadResult> UploadAsync(
|
||||
string entityType,
|
||||
Guid entityId,
|
||||
string category,
|
||||
string? description,
|
||||
string fileName,
|
||||
string contentType,
|
||||
long sizeBytes,
|
||||
Stream content,
|
||||
Guid uploadedByUserId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_uploadPolicy.IsSizeAllowed(sizeBytes))
|
||||
{
|
||||
return new DocumentUploadResult(null, DocumentUploadError.FileTooLarge);
|
||||
}
|
||||
|
||||
if (!_uploadPolicy.IsContentTypeAllowed(contentType))
|
||||
{
|
||||
return new DocumentUploadResult(null, DocumentUploadError.ContentTypeNotAllowed);
|
||||
}
|
||||
|
||||
var allowedCategories = await _valueListRepository.GetActiveValuesAsync(DocumentCategoryListKey, cancellationToken);
|
||||
if (!allowedCategories.Contains(category))
|
||||
{
|
||||
return new DocumentUploadResult(null, DocumentUploadError.InvalidCategory);
|
||||
}
|
||||
|
||||
var document = new Document
|
||||
{
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Category = category,
|
||||
Description = description,
|
||||
FileName = fileName,
|
||||
ContentType = contentType,
|
||||
SizeBytes = sizeBytes,
|
||||
UploadedByUserId = uploadedByUserId
|
||||
};
|
||||
|
||||
document.StorageKey = await _documentStorage.SaveAsync(entityType, entityId, document.Id, fileName, content, cancellationToken);
|
||||
|
||||
await _documentRepository.AddAsync(document, cancellationToken);
|
||||
await _documentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new DocumentUploadResult(document, DocumentUploadError.None);
|
||||
}
|
||||
|
||||
public async Task<DocumentUpdateResult> UpdateAsync(
|
||||
Guid id,
|
||||
string category,
|
||||
string? description,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var document = await _documentRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (document is null)
|
||||
{
|
||||
return new DocumentUpdateResult(null, DocumentUpdateError.NotFound);
|
||||
}
|
||||
|
||||
var allowedCategories = await _valueListRepository.GetActiveValuesAsync(DocumentCategoryListKey, cancellationToken);
|
||||
if (!allowedCategories.Contains(category))
|
||||
{
|
||||
return new DocumentUpdateResult(null, DocumentUpdateError.InvalidCategory);
|
||||
}
|
||||
|
||||
document.Category = category;
|
||||
document.Description = description;
|
||||
document.FileName = fileName;
|
||||
document.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _documentRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return new DocumentUpdateResult(document, DocumentUpdateError.None);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Document>> GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default)
|
||||
=> _documentRepository.GetByEntityAsync(entityType, entityId, cancellationToken);
|
||||
|
||||
public Task<Document?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _documentRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<Stream?> OpenForDownloadAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var document = await _documentRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (document is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await _documentStorage.OpenReadAsync(document.StorageKey, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _documentRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _documentRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Document>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _documentRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _documentRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _documentRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,74 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class EmployeeService : IEmployeeService
|
||||
{
|
||||
private readonly IEmployeeRepository _employeeRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
|
||||
public EmployeeService(IEmployeeRepository employeeRepository)
|
||||
public EmployeeService(
|
||||
IEmployeeRepository employeeRepository,
|
||||
IPermissionService permissionService,
|
||||
ICurrentUserService currentUserService)
|
||||
{
|
||||
_employeeRepository = employeeRepository;
|
||||
_permissionService = permissionService;
|
||||
_currentUserService = currentUserService;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Employee>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetAllAsync(cancellationToken);
|
||||
|
||||
public Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
public async Task<(IReadOnlyList<Employee> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? status,
|
||||
string? employmentType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken);
|
||||
{
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
return await _employeeRepository.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken, restrictToEmployeeId);
|
||||
}
|
||||
|
||||
public Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetByIdAsync(id, cancellationToken);
|
||||
public async Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var employee = await _employeeRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (employee is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && employee.Id != restrictToEmployeeId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Own-Scope-Filterung (siehe IPermissionService.GetScopeAsync): liefert die eigene
|
||||
/// EmployeeId, wenn der aktuelle User für Employees.<action> nur "Own" gewährt bekommt
|
||||
/// (Guid.Empty statt null, falls kein User.EmployeeId verknüpft ist — Own ohne Anker sieht
|
||||
/// dann nichts statt versehentlich alles). Null = keine Einschränkung (Scope "All" oder kein
|
||||
/// eingeloggter User, letzteres blockiert der [RequirePermission]-Endpunkt-Gate ohnehin schon).
|
||||
/// </summary>
|
||||
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(PermissionAction action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.Employees, action, cancellationToken);
|
||||
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||
}
|
||||
|
||||
public async Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -66,4 +109,29 @@ public class EmployeeService : IEmployeeService
|
||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||
return employee;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _employeeRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Employee>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _employeeRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _employeeRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _employeeRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,29 @@ public class FacilityContactService : IFacilityContactService
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
return contact;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _facilityContactRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FacilityContact>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _facilityContactRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _facilityContactRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _facilityContactRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class FacilityQualificationRateService : IFacilityQualificationRateService
|
||||
{
|
||||
private readonly IFacilityQualificationRateRepository _facilityQualificationRateRepository;
|
||||
|
||||
public FacilityQualificationRateService(IFacilityQualificationRateRepository facilityQualificationRateRepository)
|
||||
{
|
||||
_facilityQualificationRateRepository = facilityQualificationRateRepository;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FacilityQualificationRate>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default)
|
||||
=> _facilityQualificationRateRepository.GetByFacilityIdAsync(facilityId, cancellationToken);
|
||||
|
||||
public Task<FacilityQualificationRate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _facilityQualificationRateRepository.GetByIdAsync(id, cancellationToken);
|
||||
|
||||
public async Task<FacilityQualificationRate> CreateAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _facilityQualificationRateRepository.AddAsync(rate, cancellationToken);
|
||||
await _facilityQualificationRateRepository.SaveChangesAsync(cancellationToken);
|
||||
return rate;
|
||||
}
|
||||
|
||||
public async Task<FacilityQualificationRate?> UpdateAsync(Guid id, FacilityQualificationRate updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var rate = await _facilityQualificationRateRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (rate is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
rate.Qualification = updates.Qualification;
|
||||
rate.Rate = updates.Rate;
|
||||
rate.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _facilityQualificationRateRepository.UpdateAsync(rate, cancellationToken);
|
||||
await _facilityQualificationRateRepository.SaveChangesAsync(cancellationToken);
|
||||
return rate;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _facilityQualificationRateRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _facilityQualificationRateRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<FacilityQualificationRate>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _facilityQualificationRateRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _facilityQualificationRateRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _facilityQualificationRateRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,11 @@ public class FacilityService : IFacilityService
|
||||
public Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
bool followUpDueOnly,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetPagedAsync(search, crmStatus, page, pageSize, cancellationToken);
|
||||
=> _facilityRepository.GetPagedAsync(search, crmStatus, followUpDueOnly, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Facility?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -53,10 +54,36 @@ public class FacilityService : IFacilityService
|
||||
facility.BillingCity = updates.BillingCity;
|
||||
facility.BillingCountry = updates.BillingCountry;
|
||||
facility.CrmStatus = updates.CrmStatus;
|
||||
facility.FollowUpDueDate = updates.FollowUpDueDate;
|
||||
facility.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _facilityRepository.UpdateAsync(facility, cancellationToken);
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
return facility;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _facilityRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Facility>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _facilityRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _facilityRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _facilityRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IAbsenceService
|
||||
{
|
||||
Task<(IReadOnlyList<Absence> Items, int TotalCount)> GetPagedAsync(
|
||||
string? status,
|
||||
string? type,
|
||||
Guid? employeeId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<Absence?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Legt den Antrag an. Ist der aktuelle Nutzer nur mit Own-Scope berechtigt, wird
|
||||
/// <paramref name="absence"/>.EmployeeId ignoriert und serverseitig auf die eigene,
|
||||
/// per JWT verknüpfte Mitarbeiter-Id gesetzt - der Client kann sich nie als jemand
|
||||
/// anderes ausgeben. Gibt null zurück, wenn Own-Scope greift, aber kein Mitarbeiter
|
||||
/// verknüpft ist (fail-closed).
|
||||
/// </summary>
|
||||
Task<Absence?> CreateAsync(Absence absence, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bearbeitet Zeitraum/Art/Grund/Vertretung/Nachricht - nur solange der Antrag noch nicht
|
||||
/// entschieden wurde (Status "Eingereicht"), sonst <see cref="UpdateAbsenceFailureReason.AlreadyDecided"/>.
|
||||
/// Own-Scope-Aufrufer dürfen nur ihre eigenen Anträge bearbeiten (wie bei GetByIdAsync).
|
||||
/// </summary>
|
||||
Task<UpdateAbsenceResult> UpdateAsync(Guid id, Absence updates, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Absence?> DecideAsync(Guid id, string status, string? adminNote, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Der aktuell als "initial"/pending markierte Wert der ValueList "AbsenceStatus" (nicht hartkodiert, siehe AbsenceService).</summary>
|
||||
Task<string> GetInitialStatusValueAsync(CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Absence>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -16,4 +16,7 @@ public interface IContractService
|
||||
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);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Contract>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum DocumentUploadError
|
||||
{
|
||||
None,
|
||||
InvalidCategory,
|
||||
FileTooLarge,
|
||||
ContentTypeNotAllowed
|
||||
}
|
||||
|
||||
public record DocumentUploadResult(Document? Document, DocumentUploadError Error);
|
||||
|
||||
public enum DocumentUpdateError
|
||||
{
|
||||
None,
|
||||
NotFound,
|
||||
InvalidCategory
|
||||
}
|
||||
|
||||
public record DocumentUpdateResult(Document? Document, DocumentUpdateError Error);
|
||||
|
||||
public interface IDocumentService
|
||||
{
|
||||
Task<DocumentUploadResult> UploadAsync(
|
||||
string entityType,
|
||||
Guid entityId,
|
||||
string category,
|
||||
string? description,
|
||||
string fileName,
|
||||
string contentType,
|
||||
long sizeBytes,
|
||||
Stream content,
|
||||
Guid uploadedByUserId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<DocumentUpdateResult> UpdateAsync(
|
||||
Guid id,
|
||||
string category,
|
||||
string? description,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<Document>> GetByEntityAsync(string entityType, Guid entityId, CancellationToken cancellationToken = default);
|
||||
Task<Document?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Stream?> OpenForDownloadAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Document>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -15,4 +15,7 @@ public interface IEmployeeService
|
||||
Task<Employee?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<Employee> CreateAsync(Employee employee, CancellationToken cancellationToken = default);
|
||||
Task<Employee?> UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Employee>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ public interface IFacilityContactService
|
||||
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);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityContact>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface IFacilityQualificationRateService
|
||||
{
|
||||
Task<IReadOnlyList<FacilityQualificationRate>> GetByFacilityIdAsync(Guid facilityId, CancellationToken cancellationToken = default);
|
||||
Task<FacilityQualificationRate?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<FacilityQualificationRate> CreateAsync(FacilityQualificationRate rate, CancellationToken cancellationToken = default);
|
||||
Task<FacilityQualificationRate?> UpdateAsync(Guid id, FacilityQualificationRate updates, CancellationToken cancellationToken = default);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<FacilityQualificationRate>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -8,10 +8,14 @@ public interface IFacilityService
|
||||
Task<(IReadOnlyList<Facility> Items, int TotalCount)> GetPagedAsync(
|
||||
string? search,
|
||||
string? crmStatus,
|
||||
bool followUpDueOnly,
|
||||
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);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Facility>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,16 @@ public interface IOrderService
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
string? priority,
|
||||
string? requiredQualification,
|
||||
string? shiftType,
|
||||
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);
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<Order>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ public interface IRoleService
|
||||
/// <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,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action, PermissionScope Scope)> permissions,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public interface ITimeEntryService
|
||||
{
|
||||
Task<(IReadOnlyList<TimeEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
Guid? statusId,
|
||||
Guid? employeeId,
|
||||
Guid? orderId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Task<TimeEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Legt die Zeiterfassung an. Ist der aktuelle Nutzer nur mit Own-Scope berechtigt, wird
|
||||
/// <paramref name="timeEntry"/>.EmployeeId ignoriert und serverseitig auf die eigene, per JWT
|
||||
/// verknüpfte Mitarbeiter-Id gesetzt (analog AbsenceService.CreateAsync). StatusId wird immer
|
||||
/// serverseitig auf den initialen Status ("Entwurf") gesetzt. Gibt null zurück, wenn Own-Scope
|
||||
/// greift, aber kein Mitarbeiter verknüpft ist (fail-closed).
|
||||
/// </summary>
|
||||
Task<TimeEntry?> CreateAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bearbeitet Auftrag/Datum/Zeiten/Zuschlagsstunden - nur solange der aktuelle Status
|
||||
/// <see cref="ValueListItem.IsEditableByOwner"/> ist. Own-Scope-Aufrufer dürfen nur eigene
|
||||
/// Einträge bearbeiten (wie bei GetByIdAsync).
|
||||
/// </summary>
|
||||
Task<UpdateTimeEntryResult> UpdateAsync(Guid id, TimeEntry updates, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Löst die einzige Selbst-Einreichungs-Kante (RequiresApproval=false) ab dem aktuellen Status
|
||||
/// aus (Entwurf/Rückfrage -> Eingereicht). Kein Body nötig.
|
||||
/// </summary>
|
||||
Task<SubmitTimeEntryResult> SubmitAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Büro-Entscheidung entlang der Statuspipeline (z. B. ->Prüfung/->Rückfrage/->Freigegeben/->Abgerechnet).
|
||||
/// Validiert die Ziel-Transition über CanTransitionAsync und lehnt Kanten mit RequiresApproval=false ab
|
||||
/// (die gehören zu SubmitAsync, nicht hierher).
|
||||
/// </summary>
|
||||
Task<DecideTimeEntryResult> DecideAsync(Guid id, Guid statusId, string? adminNote, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<TimeEntry>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default);
|
||||
Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -42,12 +42,13 @@ public interface IUserService
|
||||
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>
|
||||
/// <summary>Upsert: existiert bereits ein Override für (module, action) bei diesem User, werden dessen Effect und Scope aktualisiert statt dupliziert.</summary>
|
||||
Task<AddPermissionOverrideResult> AddPermissionOverrideAsync(
|
||||
Guid userId,
|
||||
ModuleType module,
|
||||
PermissionAction action,
|
||||
PermissionEffect effect,
|
||||
PermissionScope scope,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
||||
|
||||
@@ -8,8 +8,8 @@ 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<ValueListItem> CreateItemAsync(string key, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, bool triggersFollowUp, CancellationToken cancellationToken = default);
|
||||
Task<ValueListItem?> UpdateItemAsync(Guid id, string value, int sortOrder, bool isDefault, bool isInitial, bool isTerminal, bool triggersFollowUp, CancellationToken cancellationToken = default);
|
||||
Task<DeleteValueListItemResult> DeleteItemAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<ValueListUsageEntry>> GetUsagesAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -23,10 +23,13 @@ public class OrderService : IOrderService
|
||||
string? search,
|
||||
Guid? statusId,
|
||||
Guid? facilityId,
|
||||
string? priority,
|
||||
string? requiredQualification,
|
||||
string? shiftType,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetPagedAsync(search, statusId, facilityId, page, pageSize, cancellationToken);
|
||||
=> _orderRepository.GetPagedAsync(search, statusId, facilityId, priority, requiredQualification, shiftType, page, pageSize, cancellationToken);
|
||||
|
||||
public Task<Order?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetByIdAsync(id, cancellationToken);
|
||||
@@ -71,4 +74,29 @@ public class OrderService : IOrderService
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateOrderResult.Ok(order);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _orderRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Order>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _orderRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _orderRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _orderRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Application.Models;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
@@ -8,30 +9,53 @@ namespace OmsorgCore.Application.Services;
|
||||
/// <summary>
|
||||
/// Implementiert die Regel aus REQUIREMENTS.md Abschnitt 3/7 und Blueprint 6.5:
|
||||
/// Rollen-Default gilt, ein individueller Override (Grant oder Revoke) gewinnt immer.
|
||||
///
|
||||
/// Der Rechte-Join (Role→RolePermissions + PermissionOverrides + Employee) ist die teuerste,
|
||||
/// pro Request wiederholte Query der gesamten API (jeder [RequirePermission]-Endpunkt löst sie
|
||||
/// aus). Deshalb wird das Ergebnis 60s in einem IMemoryCache gehalten. Der SecurityStamp-Check
|
||||
/// (Session-Killswitch, siehe Program.cs OnTokenValidated) bleibt bewusst außen vor - der muss
|
||||
/// laut CLAUDE.md "sofort" wirken, unabhängig von jeder TTL. Die TTL hier ist nur ein
|
||||
/// Sicherheitsnetz; der Normalfall ist aktive Invalidierung bei jeder Rechte-Mutation
|
||||
/// (InvalidateUserPermissions/InvalidateRolePermissionsAsync, aufgerufen aus RoleService/UserService).
|
||||
/// </summary>
|
||||
public class PermissionService : IPermissionService
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
|
||||
|
||||
public PermissionService(IUserRepository userRepository)
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public PermissionService(IUserRepository userRepository, IMemoryCache cache)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<bool> HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
User? user = await GetCachedUserAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsGranted(user, module, action);
|
||||
return ResolveScope(user, module, action) is not null;
|
||||
}
|
||||
|
||||
public async Task<PermissionScope?> GetScopeAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await GetCachedUserAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return ResolveScope(user, module, action);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<PermissionGrant>> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
User? user = await GetCachedUserAsync(userId, cancellationToken);
|
||||
if (user is null || !user.IsActive)
|
||||
{
|
||||
return Array.Empty<PermissionGrant>();
|
||||
@@ -42,9 +66,10 @@ public class PermissionService : IPermissionService
|
||||
{
|
||||
foreach (PermissionAction action in Enum.GetValues<PermissionAction>())
|
||||
{
|
||||
if (IsGranted(user, module, action))
|
||||
PermissionScope? scope = ResolveScope(user, module, action);
|
||||
if (scope is not null)
|
||||
{
|
||||
grants.Add(new PermissionGrant(module, action));
|
||||
grants.Add(new PermissionGrant(module, action, scope.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,16 +77,50 @@ public class PermissionService : IPermissionService
|
||||
return grants;
|
||||
}
|
||||
|
||||
private static bool IsGranted(User user, ModuleType module, PermissionAction action)
|
||||
public void InvalidateUserPermissions(Guid userId)
|
||||
=> _cache.Remove(CacheKey(userId));
|
||||
|
||||
public async Task InvalidateRolePermissionsAsync(Guid roleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var userIds = await _userRepository.GetUserIdsByRoleAsync(roleId, cancellationToken);
|
||||
foreach (var userId in userIds)
|
||||
{
|
||||
InvalidateUserPermissions(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<User?> GetCachedUserAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cache.TryGetValue(CacheKey(userId), out User? cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
User? user = await _userRepository.GetByIdWithPermissionsNoTrackingAsync(userId, cancellationToken);
|
||||
_cache.Set(CacheKey(userId), user, CacheDuration);
|
||||
return user;
|
||||
}
|
||||
|
||||
private static string CacheKey(Guid userId) => $"user-permissions:{userId}";
|
||||
|
||||
/// <summary>
|
||||
/// Löst Modul+Aktion für diesen User zu einem Scope auf, oder <c>null</c> wenn nicht gewährt.
|
||||
/// Ein Override ersetzt die Zelle vollständig (Grant+Scope) — es wird nicht mit dem
|
||||
/// Rollen-Scope gemergt, analog zur bestehenden Effect-Semantik.
|
||||
/// </summary>
|
||||
private static PermissionScope? ResolveScope(User user, ModuleType module, PermissionAction action)
|
||||
{
|
||||
UserPermissionOverride? override_ = user.PermissionOverrides
|
||||
.FirstOrDefault(o => o.Module == module && o.Action == action);
|
||||
|
||||
if (override_ is not null)
|
||||
{
|
||||
return override_.Effect == PermissionEffect.Grant;
|
||||
return override_.Effect == PermissionEffect.Grant ? override_.Scope : null;
|
||||
}
|
||||
|
||||
return user.Role.RolePermissions.Any(rp => rp.Module == module && rp.Action == action);
|
||||
RolePermission? rolePermission = user.Role.RolePermissions
|
||||
.FirstOrDefault(rp => rp.Module == module && rp.Action == action);
|
||||
|
||||
return rolePermission?.Scope;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ namespace OmsorgCore.Application.Services;
|
||||
public class RoleService : IRoleService
|
||||
{
|
||||
private readonly IRoleRepository _roleRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
|
||||
public RoleService(IRoleRepository roleRepository)
|
||||
public RoleService(IRoleRepository roleRepository, IPermissionService permissionService)
|
||||
{
|
||||
_roleRepository = roleRepository;
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Role>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
@@ -40,7 +42,7 @@ public class RoleService : IRoleService
|
||||
|
||||
public async Task<UpdateRolePermissionsResult> UpdatePermissionsAsync(
|
||||
Guid roleId,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action)> permissions,
|
||||
IReadOnlyList<(ModuleType Module, PermissionAction Action, PermissionScope Scope)> permissions,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var role = await _roleRepository.GetByIdWithPermissionsAsync(roleId, cancellationToken);
|
||||
@@ -52,11 +54,12 @@ public class RoleService : IRoleService
|
||||
role.RolePermissions.Clear();
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var newPermissions = permissions.Distinct()
|
||||
.Select(p => new RolePermission { RoleId = role.Id, Module = p.Module, Action = p.Action })
|
||||
var newPermissions = permissions.DistinctBy(p => (p.Module, p.Action))
|
||||
.Select(p => new RolePermission { RoleId = role.Id, Module = p.Module, Action = p.Action, Scope = p.Scope })
|
||||
.ToList();
|
||||
await _roleRepository.AddPermissionRangeAsync(newPermissions, cancellationToken);
|
||||
await _roleRepository.SaveChangesAsync(cancellationToken);
|
||||
await _permissionService.InvalidateRolePermissionsAsync(roleId, cancellationToken);
|
||||
return UpdateRolePermissionsResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum SubmitTimeEntryFailureReason
|
||||
{
|
||||
NotFound,
|
||||
NoSelfServiceTransition
|
||||
}
|
||||
|
||||
public class SubmitTimeEntryResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public SubmitTimeEntryFailureReason? FailureReason { get; init; }
|
||||
public TimeEntry? TimeEntry { get; init; }
|
||||
|
||||
public static SubmitTimeEntryResult Fail(SubmitTimeEntryFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static SubmitTimeEntryResult Ok(TimeEntry timeEntry) => new()
|
||||
{
|
||||
Success = true,
|
||||
TimeEntry = timeEntry
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using OmsorgCore.Application.Abstractions;
|
||||
using OmsorgCore.Domain.Entities;
|
||||
using OmsorgCore.Domain.Enums;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public class TimeEntryService : ITimeEntryService
|
||||
{
|
||||
private const string StatusListKey = "TimeEntryStatus";
|
||||
|
||||
private readonly ITimeEntryRepository _timeEntryRepository;
|
||||
private readonly IOrderRepository _orderRepository;
|
||||
private readonly IPermissionService _permissionService;
|
||||
private readonly ICurrentUserService _currentUserService;
|
||||
private readonly IValueListRepository _valueListRepository;
|
||||
|
||||
public TimeEntryService(
|
||||
ITimeEntryRepository timeEntryRepository,
|
||||
IOrderRepository orderRepository,
|
||||
IPermissionService permissionService,
|
||||
ICurrentUserService currentUserService,
|
||||
IValueListRepository valueListRepository)
|
||||
{
|
||||
_timeEntryRepository = timeEntryRepository;
|
||||
_orderRepository = orderRepository;
|
||||
_permissionService = permissionService;
|
||||
_currentUserService = currentUserService;
|
||||
_valueListRepository = valueListRepository;
|
||||
}
|
||||
|
||||
public async Task<(IReadOnlyList<TimeEntry> Items, int TotalCount)> GetPagedAsync(
|
||||
Guid? statusId,
|
||||
Guid? employeeId,
|
||||
Guid? orderId,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
return await _timeEntryRepository.GetPagedAsync(statusId, employeeId, orderId, page, pageSize, cancellationToken, restrictToEmployeeId);
|
||||
}
|
||||
|
||||
public async Task<TimeEntry?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeEntry = await _timeEntryRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (timeEntry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.View, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && timeEntry.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return timeEntry;
|
||||
}
|
||||
|
||||
public async Task<TimeEntry?> CreateAsync(TimeEntry timeEntry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (await _orderRepository.GetByIdAsync(timeEntry.OrderId, cancellationToken) is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Create, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue)
|
||||
{
|
||||
if (restrictToEmployeeId.Value == Guid.Empty)
|
||||
{
|
||||
// Own-Scope, aber kein User.EmployeeId verknüpft - fail-closed (analog AbsenceService).
|
||||
return null;
|
||||
}
|
||||
|
||||
timeEntry.EmployeeId = restrictToEmployeeId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
var ownEmployeeId = _currentUserService.EmployeeId;
|
||||
if (ownEmployeeId is null || ownEmployeeId == Guid.Empty)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
timeEntry.EmployeeId = ownEmployeeId.Value;
|
||||
}
|
||||
|
||||
var initialStatus = await _valueListRepository.GetInitialItemAsync(StatusListKey, cancellationToken)
|
||||
?? throw new InvalidOperationException("Kein initialer Zeiterfassungsstatus konfiguriert (DbSeeder.SeedValueListsAsync fehlt).");
|
||||
timeEntry.StatusId = initialStatus.Id;
|
||||
|
||||
await _timeEntryRepository.AddAsync(timeEntry, cancellationToken);
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
return timeEntry;
|
||||
}
|
||||
|
||||
public async Task<UpdateTimeEntryResult> UpdateAsync(Guid id, TimeEntry updates, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeEntry = await _timeEntryRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (timeEntry is null)
|
||||
{
|
||||
return UpdateTimeEntryResult.Fail(UpdateTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Edit, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && timeEntry.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
return UpdateTimeEntryResult.Fail(UpdateTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
var currentStatus = await _valueListRepository.GetItemByIdAsync(timeEntry.StatusId, cancellationToken);
|
||||
if (currentStatus is null || !currentStatus.IsEditableByOwner)
|
||||
{
|
||||
return UpdateTimeEntryResult.Fail(UpdateTimeEntryFailureReason.NotEditable);
|
||||
}
|
||||
|
||||
if (updates.OrderId != timeEntry.OrderId && await _orderRepository.GetByIdAsync(updates.OrderId, cancellationToken) is null)
|
||||
{
|
||||
return UpdateTimeEntryResult.Fail(UpdateTimeEntryFailureReason.OrderNotFound);
|
||||
}
|
||||
|
||||
timeEntry.OrderId = updates.OrderId;
|
||||
timeEntry.Date = updates.Date;
|
||||
timeEntry.Start = updates.Start;
|
||||
timeEntry.End = updates.End;
|
||||
timeEntry.BreakDuration = updates.BreakDuration;
|
||||
timeEntry.NightHours = updates.NightHours;
|
||||
timeEntry.SaturdayHours = updates.SaturdayHours;
|
||||
timeEntry.SundayHours = updates.SundayHours;
|
||||
timeEntry.HolidayHours = updates.HolidayHours;
|
||||
timeEntry.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _timeEntryRepository.UpdateAsync(timeEntry, cancellationToken);
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
return UpdateTimeEntryResult.Ok(timeEntry);
|
||||
}
|
||||
|
||||
public async Task<SubmitTimeEntryResult> SubmitAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeEntry = await _timeEntryRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (timeEntry is null)
|
||||
{
|
||||
return SubmitTimeEntryResult.Fail(SubmitTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
Guid? restrictToEmployeeId = await ResolveOwnScopeRestrictionAsync(PermissionAction.Edit, cancellationToken);
|
||||
if (restrictToEmployeeId.HasValue && timeEntry.EmployeeId != restrictToEmployeeId.Value)
|
||||
{
|
||||
return SubmitTimeEntryResult.Fail(SubmitTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
var transition = await _valueListRepository.GetSelfServiceTransitionAsync(timeEntry.StatusId, cancellationToken);
|
||||
if (transition is null)
|
||||
{
|
||||
return SubmitTimeEntryResult.Fail(SubmitTimeEntryFailureReason.NoSelfServiceTransition);
|
||||
}
|
||||
|
||||
timeEntry.StatusId = transition.ToItemId;
|
||||
timeEntry.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _timeEntryRepository.UpdateAsync(timeEntry, cancellationToken);
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
return SubmitTimeEntryResult.Ok(timeEntry);
|
||||
}
|
||||
|
||||
public async Task<DecideTimeEntryResult> DecideAsync(Guid id, Guid statusId, string? adminNote, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var timeEntry = await _timeEntryRepository.GetByIdAsync(id, cancellationToken);
|
||||
if (timeEntry is null)
|
||||
{
|
||||
return DecideTimeEntryResult.Fail(DecideTimeEntryFailureReason.NotFound);
|
||||
}
|
||||
|
||||
var transition = await _valueListRepository.GetTransitionAsync(timeEntry.StatusId, statusId, cancellationToken);
|
||||
if (transition is null || !transition.RequiresApproval)
|
||||
{
|
||||
return DecideTimeEntryResult.Fail(DecideTimeEntryFailureReason.InvalidStatusTransition);
|
||||
}
|
||||
|
||||
timeEntry.StatusId = statusId;
|
||||
timeEntry.AdminNote = adminNote;
|
||||
timeEntry.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _timeEntryRepository.UpdateAsync(timeEntry, cancellationToken);
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
return DecideTimeEntryResult.Ok(timeEntry);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deleted = await _timeEntryRepository.SoftDeleteAsync(id, cancellationToken);
|
||||
if (deleted)
|
||||
{
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TimeEntry>> GetDeletedAsync(string? search, CancellationToken cancellationToken = default)
|
||||
=> _timeEntryRepository.GetDeletedAsync(search, cancellationToken);
|
||||
|
||||
public async Task<bool> RestoreAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var restored = await _timeEntryRepository.RestoreAsync(id, cancellationToken);
|
||||
if (restored)
|
||||
{
|
||||
await _timeEntryRepository.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return restored;
|
||||
}
|
||||
|
||||
/// <summary>Own-Scope-Filterung, siehe AbsenceService.ResolveOwnScopeRestrictionAsync (analoges Muster für ModuleType.TimeEntries).</summary>
|
||||
private async Task<Guid?> ResolveOwnScopeRestrictionAsync(PermissionAction action, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_currentUserService.UserId is not { } userId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var scope = await _permissionService.GetScopeAsync(userId, ModuleType.TimeEntries, action, cancellationToken);
|
||||
return scope == PermissionScope.Own ? (_currentUserService.EmployeeId ?? Guid.Empty) : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum UpdateAbsenceFailureReason
|
||||
{
|
||||
NotFound,
|
||||
AlreadyDecided
|
||||
}
|
||||
|
||||
public class UpdateAbsenceResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public UpdateAbsenceFailureReason? FailureReason { get; init; }
|
||||
public Absence? Absence { get; init; }
|
||||
|
||||
public static UpdateAbsenceResult Fail(UpdateAbsenceFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static UpdateAbsenceResult Ok(Absence absence) => new()
|
||||
{
|
||||
Success = true,
|
||||
Absence = absence
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using OmsorgCore.Domain.Entities;
|
||||
|
||||
namespace OmsorgCore.Application.Services;
|
||||
|
||||
public enum UpdateTimeEntryFailureReason
|
||||
{
|
||||
NotFound,
|
||||
NotEditable,
|
||||
OrderNotFound
|
||||
}
|
||||
|
||||
public class UpdateTimeEntryResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public UpdateTimeEntryFailureReason? FailureReason { get; init; }
|
||||
public TimeEntry? TimeEntry { get; init; }
|
||||
|
||||
public static UpdateTimeEntryResult Fail(UpdateTimeEntryFailureReason reason) => new()
|
||||
{
|
||||
Success = false,
|
||||
FailureReason = reason
|
||||
};
|
||||
|
||||
public static UpdateTimeEntryResult Ok(TimeEntry timeEntry) => new()
|
||||
{
|
||||
Success = true,
|
||||
TimeEntry = timeEntry
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class UserService : IUserService
|
||||
private readonly IPasswordResetService _passwordResetService;
|
||||
private readonly IRefreshTokenRepository _refreshTokenRepository;
|
||||
private readonly IPasswordPolicy _passwordPolicy;
|
||||
private readonly IPermissionService _permissionService;
|
||||
|
||||
public UserService(
|
||||
IUserRepository userRepository,
|
||||
@@ -22,7 +23,8 @@ public class UserService : IUserService
|
||||
IPasswordHasher passwordHasher,
|
||||
IPasswordResetService passwordResetService,
|
||||
IRefreshTokenRepository refreshTokenRepository,
|
||||
IPasswordPolicy passwordPolicy)
|
||||
IPasswordPolicy passwordPolicy,
|
||||
IPermissionService permissionService)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_employeeRepository = employeeRepository;
|
||||
@@ -31,6 +33,7 @@ public class UserService : IUserService
|
||||
_passwordResetService = passwordResetService;
|
||||
_refreshTokenRepository = refreshTokenRepository;
|
||||
_passwordPolicy = passwordPolicy;
|
||||
_permissionService = permissionService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<UserSummary>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
@@ -220,6 +223,7 @@ public class UserService : IUserService
|
||||
}
|
||||
|
||||
user.RoleId = roleId;
|
||||
_permissionService.InvalidateUserPermissions(userId);
|
||||
|
||||
var wasActive = user.IsActive;
|
||||
user.IsActive = isActive;
|
||||
@@ -250,7 +254,7 @@ public class UserService : IUserService
|
||||
}
|
||||
|
||||
return user.PermissionOverrides
|
||||
.Select(o => new PermissionOverrideSummary(o.Id, o.Module, o.Action, o.Effect))
|
||||
.Select(o => new PermissionOverrideSummary(o.Id, o.Module, o.Action, o.Effect, o.Scope))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -259,6 +263,7 @@ public class UserService : IUserService
|
||||
ModuleType module,
|
||||
PermissionAction action,
|
||||
PermissionEffect effect,
|
||||
PermissionScope scope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken);
|
||||
@@ -273,15 +278,17 @@ public class UserService : IUserService
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.Effect = effect;
|
||||
existing.Scope = scope;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing = new UserPermissionOverride { UserId = user.Id, Module = module, Action = action, Effect = effect };
|
||||
existing = new UserPermissionOverride { UserId = user.Id, Module = module, Action = action, Effect = effect, Scope = scope };
|
||||
await _userRepository.AddPermissionOverrideAsync(existing, cancellationToken);
|
||||
}
|
||||
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
return AddPermissionOverrideResult.Ok(new PermissionOverrideSummary(existing.Id, module, action, effect));
|
||||
_permissionService.InvalidateUserPermissions(userId);
|
||||
return AddPermissionOverrideResult.Ok(new PermissionOverrideSummary(existing.Id, module, action, effect, scope));
|
||||
}
|
||||
|
||||
public async Task<RemovePermissionOverrideResult> RemovePermissionOverrideAsync(
|
||||
@@ -301,6 +308,7 @@ public class UserService : IUserService
|
||||
|
||||
user.PermissionOverrides.Remove(existing);
|
||||
await _userRepository.SaveChangesAsync(cancellationToken);
|
||||
_permissionService.InvalidateUserPermissions(userId);
|
||||
return RemovePermissionOverrideResult.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user