From b6c1389c55a4860d8b4f39cd40915b6deaf73fa5 Mon Sep 17 00:00:00 2001 From: Felix Kemmler Date: Fri, 7 Aug 2026 14:21:37 +0200 Subject: [PATCH] Reorganize into monorepo layout, move mitarbeiter-app to legacy reference Consolidates the previously separate omsorgapp and omsorgCore repos (each had their own nested .git with GitHub history) plus the old root-level website/mitarbeiter-app into a single monorepo, matching the structure already documented in the root CLAUDE.md. Also moves the PHP employee app aside as omsorgWeb/mitarbeiter-app-legacy/ to serve as a template for a ground-up rewrite. Fixes .gitignore in the same pass: the config-secrets/uploads/data patterns were unanchored (relative to repo root, not depth-agnostic), so they silently stopped matching once the app moved under omsorgWeb/. Patterns are now **/-prefixed and cover both mitarbeiter-app and mitarbeiter-app-legacy, keeping DB/SMTP credentials and uploaded employee documents out of version control. Co-Authored-By: Claude Sonnet 5 --- .gitignore | 18 +- CLAUDE.md | 135 +- REQUIREMENTS.md | 261 ++ mitarbeiter-app/actions/save-profile.php | 53 - .../actions/submit-urlaubsantrag.php | 56 - mitarbeiter-app/lib/auth.php | 111 - omsorg.md | 238 ++ omsorgCore/.config/dotnet-tools.json | 12 + omsorgCore/.gitignore | 12 + omsorgCore/CLAUDE.md | 179 ++ omsorgCore/CONFIGURATION.md | 87 + omsorgCore/OmsorgCore.sln | 71 + .../Contracts/ChangePasswordRequest.cs | 3 + .../Contracts/CreateEmployeeRequest.cs | 19 + .../Contracts/CreateRoleRequest.cs | 3 + .../Contracts/CreateUserRequest.cs | 10 + .../Contracts/EmployeeResponse.cs | 22 + .../Contracts/ForgotPasswordRequestRequest.cs | 3 + .../ForgotPasswordRequestResponse.cs | 4 + .../Contracts/ForgotPasswordResetRequest.cs | 3 + .../Contracts/ForgotPasswordVerifyRequest.cs | 3 + .../Contracts/ForgotPasswordVerifyResponse.cs | 3 + .../OmsorgCore.Api/Contracts/LoginRequest.cs | 3 + .../OmsorgCore.Api/Contracts/LoginResponse.cs | 3 + .../OmsorgCore.Api/Contracts/LogoutRequest.cs | 3 + .../OmsorgCore.Api/Contracts/MeResponse.cs | 13 + .../OmsorgCore.Api/Contracts/PagedResponse.cs | 3 + .../PasswordResetTemplateResponse.cs | 3 + .../OmsorgCore.Api/Contracts/PermissionDto.cs | 3 + .../Contracts/RefreshRequest.cs | 3 + .../Contracts/ResetUserPasswordRequest.cs | 4 + .../OmsorgCore.Api/Contracts/RoleResponse.cs | 3 + .../Contracts/SendTestEmailRequest.cs | 3 + .../Contracts/SessionResponse.cs | 3 + .../Contracts/UpdateEmployeeRequest.cs | 21 + .../Contracts/UpdateUserRequest.cs | 3 + .../OmsorgCore.Api/Contracts/UserResponse.cs | 3 + .../Controllers/AdminEmailController.cs | 67 + .../Controllers/AdminSessionsController.cs | 49 + .../Controllers/AuthController.cs | 163 ++ .../Controllers/EmployeesController.cs | 289 +++ .../Controllers/HealthController.cs | 33 + .../Controllers/RolesController.cs | 51 + .../Controllers/UsersController.cs | 167 ++ .../src/OmsorgCore.Api/OmsorgCore.Api.csproj | 25 + .../src/OmsorgCore.Api/OmsorgCore.Api.http | 6 + omsorgCore/src/OmsorgCore.Api/Program.cs | 161 ++ .../Properties/launchSettings.json | 41 + .../Security/CurrentUserService.cs | 27 + .../Security/RequirePermissionAttribute.cs | 41 + .../src/OmsorgCore.Api/appsettings.json | 57 + .../Abstractions/EmailMessage.cs | 8 + .../Abstractions/ICurrentUserService.cs | 12 + .../Abstractions/IEmailSender.cs | 6 + .../Abstractions/IEmployeeRepository.cs | 19 + .../Abstractions/IJwtTokenGenerator.cs | 8 + .../Abstractions/ILoginAttemptRepository.cs | 16 + .../Abstractions/ILoginLockoutPolicy.cs | 8 + .../Abstractions/IPasswordHasher.cs | 7 + .../IPasswordResetCodeGenerator.cs | 23 + .../IPasswordResetCodeRepository.cs | 21 + .../IPasswordResetEmailComposer.cs | 11 + .../Abstractions/IPermissionService.cs | 17 + .../Abstractions/IRefreshTokenGenerator.cs | 10 + .../Abstractions/IRefreshTokenRepository.cs | 20 + .../Abstractions/IRoleRepository.cs | 12 + .../Abstractions/IUserInviteEmailComposer.cs | 10 + .../Abstractions/IUserRepository.cs | 27 + .../DependencyInjection.cs | 21 + .../Models/PermissionGrant.cs | 6 + .../Models/UserProfile.cs | 19 + .../OmsorgCore.Application.csproj | 17 + .../Services/AdminResetPasswordResult.cs | 31 + .../Services/AuthResult.cs | 24 + .../Services/AuthService.cs | 155 ++ .../Services/CreateRoleResult.cs | 28 + .../Services/CreateUserResult.cs | 38 + .../Services/EmployeeService.cs | 69 + .../Services/IAuthService.cs | 17 + .../Services/IEmployeeService.cs | 18 + .../Services/IPasswordResetService.cs | 17 + .../Services/IRoleService.cs | 9 + .../Services/ISessionAdminService.cs | 12 + .../Services/IUserService.cs | 36 + .../Services/PasswordResetRequestResult.cs | 27 + .../Services/PasswordResetService.cs | 139 ++ .../Services/PasswordResetVerifyResult.cs | 14 + .../Services/PermissionService.cs | 67 + .../Services/RoleService.cs | 36 + .../Services/SessionAdminService.cs | 54 + .../Services/SessionInfo.cs | 3 + .../Services/UpdateUserResult.cs | 21 + .../Services/UserCreationMode.cs | 10 + .../Services/UserService.cs | 222 ++ .../Services/UserSummary.cs | 4 + .../Common/AuditableEntity.cs | 11 + .../src/OmsorgCore.Domain/Common/Entity.cs | 6 + .../OmsorgCore.Domain/Entities/Contract.cs | 22 + .../OmsorgCore.Domain/Entities/Employee.cs | 32 + .../OmsorgCore.Domain/Entities/Facility.cs | 17 + .../src/OmsorgCore.Domain/Entities/Invoice.cs | 24 + .../Entities/LoginAttempt.cs | 15 + .../src/OmsorgCore.Domain/Entities/Order.cs | 17 + .../Entities/PasswordResetCode.cs | 35 + .../Entities/RefreshToken.cs | 22 + .../src/OmsorgCore.Domain/Entities/Role.cs | 15 + .../Entities/RolePermission.cs | 16 + .../OmsorgCore.Domain/Entities/TimeEntry.cs | 24 + .../src/OmsorgCore.Domain/Entities/User.cs | 36 + .../Entities/UserPermissionOverride.cs | 19 + .../src/OmsorgCore.Domain/Enums/ModuleType.cs | 17 + .../Enums/PermissionAction.cs | 14 + .../Enums/PermissionEffect.cs | 10 + .../OmsorgCore.Domain.csproj | 9 + .../OmsorgCore.Email/ConsoleEmailSender.cs | 26 + .../OmsorgCore.Email/DependencyInjection.cs | 28 + .../OmsorgCore.Email/EmailAccountOptions.cs | 14 + .../src/OmsorgCore.Email/EmailOptions.cs | 36 + .../OmsorgCore.Email/EmailTemplateRenderer.cs | 19 + .../OmsorgCore.Email/OmsorgCore.Email.csproj | 21 + .../PasswordResetEmailComposer.cs | 30 + .../PasswordResetEmailTemplate.cs | 13 + .../src/OmsorgCore.Email/SmtpEmailSender.cs | 57 + .../src/OmsorgCore.Email/SmtpOptions.cs | 12 + .../UserInviteEmailComposer.cs | 30 + .../UserInviteEmailTemplate.cs | 13 + .../OmsorgCore.Engine/DependencyInjection.cs | 18 + .../Events/DomainEventDispatcher.cs | 36 + .../Events/EmployeeCreatedEvent.cs | 16 + .../OmsorgCore.Engine/Events/IDomainEvent.cs | 10 + .../Events/IDomainEventDispatcher.cs | 11 + .../Events/IDomainEventHandler.cs | 6 + .../Events/PasswordResetRequestedEvent.cs | 15 + .../Events/UserInvitedEvent.cs | 17 + .../Handlers/EmployeeCreatedHandler.cs | 30 + .../Handlers/PasswordResetRequestedHandler.cs | 31 + .../Handlers/UserInvitedHandler.cs | 31 + .../OmsorgCore.Engine.csproj | 20 + .../DependencyInjection.cs | 42 + .../OmsorgCore.Infrastructure.csproj | 26 + .../Configurations/ContractConfiguration.cs | 26 + .../Configurations/EmployeeConfiguration.cs | 31 + .../Configurations/FacilityConfiguration.cs | 16 + .../Configurations/InvoiceConfiguration.cs | 24 + .../LoginAttemptConfiguration.cs | 17 + .../Configurations/OrderConfiguration.cs | 20 + .../PasswordResetCodeConfiguration.cs | 25 + .../RefreshTokenConfiguration.cs | 28 + .../Configurations/RoleConfiguration.cs | 16 + .../RolePermissionConfiguration.cs | 20 + .../Configurations/TimeEntryConfiguration.cs | 25 + .../Configurations/UserConfiguration.cs | 28 + .../UserPermissionOverrideConfiguration.cs | 20 + .../Persistence/DbSeeder.cs | 50 + .../20260804154905_InitialCreate.Designer.cs | 515 ++++ .../20260804154905_InitialCreate.cs | 358 +++ ...0260804183457_AddRefreshTokens.Designer.cs | 569 +++++ .../20260804183457_AddRefreshTokens.cs | 67 + ...804192810_AddUserSecurityStamp.Designer.cs | 572 +++++ .../20260804192810_AddUserSecurityStamp.cs | 30 + ...mployeeContactFieldConstraints.Designer.cs | 575 +++++ ...2305_AddEmployeeContactFieldConstraints.cs | 78 + ...5094410_AddAuditableSoftDelete.Designer.cs | 617 +++++ .../20260805094410_AddAuditableSoftDelete.cs | 166 ++ ...06160932_AddPasswordResetCodes.Designer.cs | 676 ++++++ .../20260806160932_AddPasswordResetCodes.cs | 59 + ...04_AddEmployeeStammdatenFields.Designer.cs | 696 ++++++ ...60806175104_AddEmployeeStammdatenFields.cs | 73 + ...loyeeAddressToStructuredFields.Designer.cs | 708 ++++++ ...RenameEmployeeAddressToStructuredFields.cs | 61 + ...2137_AddUserMustChangePassword.Designer.cs | 711 ++++++ ...0260807092137_AddUserMustChangePassword.cs | 29 + ...LoginAttemptsAndEmployeeAvatar.Designer.cs | 743 ++++++ ...01715_AddLoginAttemptsAndEmployeeAvatar.cs | 52 + .../OmsorgCoreDbContextModelSnapshot.cs | 740 ++++++ .../Persistence/OmsorgCoreDbContext.cs | 32 + .../Repositories/EmployeeRepository.cs | 75 + .../Repositories/LoginAttemptRepository.cs | 34 + .../PasswordResetCodeRepository.cs | 62 + .../Repositories/RefreshTokenRepository.cs | 52 + .../Repositories/RoleRepository.cs | 31 + .../Repositories/UserRepository.cs | 54 + .../Security/JwtOptions.cs | 16 + .../Security/JwtTokenGenerator.cs | 44 + .../Security/LoginAttemptOptions.cs | 9 + .../Security/LoginLockoutPolicy.cs | 17 + .../Security/PasswordHasher.cs | 18 + .../Security/PasswordResetCodeGenerator.cs | 43 + .../Security/PasswordResetOptions.cs | 19 + .../Security/RefreshTokenGenerator.cs | 25 + .../Security/RefreshTokenOptions.cs | 13 + .../Security/SeedOptions.cs | 14 + .../tests/OmsorgCore.Tests/GlobalUsings.cs | 1 + .../OmsorgCore.Tests/OmsorgCore.Tests.csproj | 31 + .../Services/AuthServiceTests.cs | 214 ++ .../Services/EmailTemplateRendererTests.cs | 40 + .../Services/EmployeeServiceTests.cs | 177 ++ .../Services/PasswordResetServiceTests.cs | 215 ++ .../Services/PermissionServiceTests.cs | 96 + .../Services/RoleServiceTests.cs | 45 + .../Services/SessionAdminServiceTests.cs | 105 + .../Services/UserServiceTests.cs | 342 +++ .../TestDoubles/FakeRepositories.cs | 287 +++ omsorgWeb/CLAUDE.md | 118 + README.txt => omsorgWeb/README.txt | 0 .../assets}/einrichtungen.svg | 0 .../assets}/omsorg-bg-full.png | Bin .../assets}/omsorg-logo-new.png | Bin {assets => omsorgWeb/assets}/omsorg-logo.png | Bin .../assets}/omsorg-team-transparent.png | Bin .../assets}/omsorg-wordmark-new.png | Bin .../assets}/pflege-bewerbung.jpg | Bin .../assets}/pflege-einrichtungen.jpg | Bin {assets => omsorgWeb/assets}/pflege-hero.jpg | Bin .../assets}/pflege-training.jpg | Bin .../assets}/pflegekraefte.svg | 0 {assets => omsorgWeb/assets}/team.svg | 0 .../datenschutz.html | 0 impressum.html => omsorgWeb/impressum.html | 0 index.html => omsorgWeb/index.html | 0 .../mitarbeiter-app-legacy}/.htaccess | 0 .../actions/admin-action.php | 0 .../actions/delete-dokument.php | 0 .../actions/downloads-action.php | 0 .../actions/downloads-reorder.php | 0 .../actions/einsatzanweisung-action.php | 0 .../actions/fortbildung-material-action.php | 0 .../actions/fortbildung-material-reorder.php | 0 .../actions/logout.php | 0 .../actions/news-action.php | 0 .../actions/save-dienstplan.php | 0 .../actions/save-profile.php | 74 + .../actions/submit-abwesenheitsantrag.php | 0 .../actions/submit-benefitsantrag.php | 0 .../actions/submit-einsatzbewertung.php | 0 .../actions/submit-fortbildungsantrag.php | 0 .../actions/submit-stundennachweis.php | 0 .../actions/submit-urlaubsantrag.php | 73 + .../actions/submit-werben.php | 0 .../actions/upload-avatar.php | 34 +- .../actions/upload-dokument.php | 0 .../mitarbeiter-app-legacy}/app.css | 0 .../assets/Liberty.jpeg | Bin .../assets/omsorg-bg-full.png | Bin .../assets/omsorg-logo-new.png | Bin .../assets/omsorg-team-transparent.png | Bin .../assets/omsorg-wordmark-new.png | Bin .../1001fonts-kindelserif-eula.txt | 0 .../kindelserif/KindelSerif_PersonalUse.otf | Bin .../font/visby/VisbyBold Italic.otf | Bin .../font/visby/VisbyBold.otf | Bin .../font/visby/VisbyExtrabold Italic.otf | Bin .../font/visby/VisbyExtrabold.otf | Bin .../font/visby/VisbyHeavy Italic.otf | Bin .../font/visby/VisbyHeavy.otf | Bin .../font/visby/VisbyItalic.otf | Bin .../font/visby/VisbyLight Italic.otf | Bin .../font/visby/VisbyLight.otf | Bin .../font/visby/VisbyMedium Italic.otf | Bin .../font/visby/VisbyMedium.otf | Bin .../font/visby/VisbyRegular.otf | Bin .../font/visby/VisbySemibold Italic.otf | Bin .../font/visby/VisbySemibold.otf | Bin .../font/visby/VisbyThin Italic.otf | Bin .../font/visby/VisbyThin.otf | Bin .../mitarbeiter-app-legacy}/index.php | 51 +- .../mitarbeiter-app-legacy}/lib/.htaccess | 0 omsorgWeb/mitarbeiter-app-legacy/lib/auth.php | 209 ++ .../lib/config.secret.example.php | 2 + .../mitarbeiter-app-legacy}/lib/db.php | 0 .../mitarbeiter-app-legacy}/lib/layout.php | 0 .../mitarbeiter-app-legacy}/lib/mail.php | 0 .../lib/omsorgCoreClient.php | 145 ++ .../mitarbeiter-app-legacy}/lib/upload.php | 0 .../manifest.webmanifest | 0 .../migrations/.htaccess | 0 .../migrations/001_initial_schema.php | 0 .../migrations/002_avatar_column.php | 0 .../migrations/003_einsatzanweisung.php | 0 .../migrations/004_login_attempts.php | 0 .../migrations/005_urlaubsantrag_anhang.php | 8 + .../migrations/006_omsorgcore_id_mapping.php | 15 + .../pages/abwesenheitsantrag.php | 0 .../pages/admin-dienstplan.php | 0 .../mitarbeiter-app-legacy}/pages/admin.php | 0 .../pages/benefitsantrag.php | 0 .../pages/change-password.php | 75 + .../pages/dashboard.php | 0 .../pages/dienstplan.php | 0 .../pages/dokument-serve.php | 0 .../pages/dokumentenarchiv.php | 0 .../pages/download-serve.php | 0 .../pages/downloads.php | 0 .../pages/einsatzanweisung-serve.php | 0 .../pages/einsatzanweisung.php | 0 .../pages/einsatzbewertung.php | 0 .../pages/forgot-password.php | 125 + .../pages/fortbildung-serve.php | 0 .../pages/fortbildungsantrag.php | 0 .../pages/settings.php | 0 .../pages/stundennachweis.php | 0 .../pages/upload-serve.php | 0 .../pages/urlaubsantrag.php | 7 +- .../mitarbeiter-app-legacy}/pages/werben.php | 0 .../mitarbeiter-app-legacy}/service-worker.js | 0 .../mitarbeiter-app-legacy}/setup.php | 0 send-form.php => omsorgWeb/send-form.php | 0 .../send-status-template.php | 0 style.css => omsorgWeb/style.css | 0 omsorgapp/.gitignore | 13 + omsorgapp/CLAUDE.md | 46 + omsorgapp/README.md | 24 + omsorgapp/docs/OMSORG-BLUEPRINT.md | 1361 +++++++++++ omsorgapp/electron/backend/authClient.cjs | 73 + omsorgapp/electron/backend/config.cjs | 6 + .../electron/backend/employeesClient.cjs | 28 + omsorgapp/electron/backend/httpClient.cjs | 34 + omsorgapp/electron/backend/usersClient.cjs | 12 + omsorgapp/electron/main.cjs | 296 +++ omsorgapp/electron/preload.cjs | 36 + omsorgapp/index.html | 1 + omsorgapp/package-lock.json | 2132 +++++++++++++++++ omsorgapp/package.json | 25 + omsorgapp/src/app/AuthContext.jsx | 82 + omsorgapp/src/app/app.jsx | 107 + omsorgapp/src/app/navPermissions.js | 14 + omsorgapp/src/assets/omsorg_logo.png | Bin 0 -> 33617 bytes omsorgapp/src/components/Header.jsx | 52 + omsorgapp/src/components/OmsorgCard.jsx | 38 + omsorgapp/src/components/Sidebar.jsx | 103 + omsorgapp/src/components/ui/OmsorgBadge.jsx | 58 + omsorgapp/src/components/ui/OmsorgButton.jsx | 42 + .../src/components/ui/OmsorgPagination.jsx | 31 + .../src/components/ui/OmsorgStatCard.jsx | 51 + omsorgapp/src/layouts/AppLayout.jsx | 31 + omsorgapp/src/main.jsx | 13 + omsorgapp/src/main.legacy.jsx | 56 + .../src/modules/auth/ChangePasswordScreen.jsx | 93 + .../auth/ForgotPasswordNewPasswordPage.jsx | 77 + .../modules/auth/ForgotPasswordPinPage.jsx | 81 + .../auth/ForgotPasswordUsernamePage.jsx | 72 + omsorgapp/src/modules/auth/LoginPage.jsx | 76 + .../src/modules/debug/DebugSessionsPage.jsx | 180 ++ .../employees/CreateEmployeeDialog.jsx | 58 + .../employees/CreateUserAccountDialog.jsx | 182 ++ .../modules/employees/EditEmployeeDialog.jsx | 59 + .../modules/employees/EmployeeDetailPanel.jsx | 197 ++ .../src/modules/employees/EmployeeForm.jsx | 214 ++ .../src/modules/employees/EmployeeTabs.jsx | 56 + .../src/modules/employees/EmployeesPage.jsx | 328 +++ omsorgapp/src/modules/home/ContractWidget.jsx | 142 ++ omsorgapp/src/modules/home/HomePage.jsx | 88 + omsorgapp/src/modules/home/HomeStats.jsx | 70 + omsorgapp/src/style.backup.css | 1 + omsorgapp/src/style.css | 1607 +++++++++++++ 355 files changed, 24348 insertions(+), 361 deletions(-) create mode 100644 REQUIREMENTS.md delete mode 100644 mitarbeiter-app/actions/save-profile.php delete mode 100644 mitarbeiter-app/actions/submit-urlaubsantrag.php delete mode 100644 mitarbeiter-app/lib/auth.php create mode 100644 omsorg.md create mode 100644 omsorgCore/.config/dotnet-tools.json create mode 100644 omsorgCore/.gitignore create mode 100644 omsorgCore/CLAUDE.md create mode 100644 omsorgCore/CONFIGURATION.md create mode 100644 omsorgCore/OmsorgCore.sln create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/ChangePasswordRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/CreateRoleRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/CreateUserRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordResetRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/LoginRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/LoginResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/LogoutRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/MeResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/PagedResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/PasswordResetTemplateResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/PermissionDto.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/RefreshRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/ResetUserPasswordRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/RoleResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/SendTestEmailRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/SessionResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/UpdateUserRequest.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Contracts/UserResponse.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Controllers/AdminEmailController.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Controllers/AdminSessionsController.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Controllers/AuthController.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Controllers/EmployeesController.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Controllers/HealthController.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Controllers/UsersController.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.csproj create mode 100644 omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.http create mode 100644 omsorgCore/src/OmsorgCore.Api/Program.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Properties/launchSettings.json create mode 100644 omsorgCore/src/OmsorgCore.Api/Security/CurrentUserService.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs create mode 100644 omsorgCore/src/OmsorgCore.Api/appsettings.json create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/EmailMessage.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/ICurrentUserService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IEmailSender.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IJwtTokenGenerator.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginAttemptRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginLockoutPolicy.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordHasher.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeGenerator.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetEmailComposer.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IPermissionService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenGenerator.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IUserInviteEmailComposer.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Abstractions/IUserRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Models/PermissionGrant.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Models/UserProfile.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/OmsorgCore.Application.csproj create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/AdminResetPasswordResult.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/AuthResult.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/AuthService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/CreateRoleResult.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/CreateUserResult.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/EmployeeService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/IAuthService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/IEmployeeService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/IPasswordResetService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/ISessionAdminService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/IUserService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/PasswordResetRequestResult.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/PasswordResetService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/PasswordResetVerifyResult.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/PermissionService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/SessionAdminService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/SessionInfo.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/UpdateUserResult.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/UserCreationMode.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/UserService.cs create mode 100644 omsorgCore/src/OmsorgCore.Application/Services/UserSummary.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Common/AuditableEntity.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Common/Entity.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/Contract.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/Employee.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/Invoice.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/LoginAttempt.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/Order.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/PasswordResetCode.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/RefreshToken.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/Role.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/RolePermission.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/TimeEntry.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/User.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Entities/UserPermissionOverride.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Enums/PermissionAction.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/Enums/PermissionEffect.cs create mode 100644 omsorgCore/src/OmsorgCore.Domain/OmsorgCore.Domain.csproj create mode 100644 omsorgCore/src/OmsorgCore.Email/ConsoleEmailSender.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/DependencyInjection.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/EmailAccountOptions.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/EmailOptions.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/EmailTemplateRenderer.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/OmsorgCore.Email.csproj create mode 100644 omsorgCore/src/OmsorgCore.Email/PasswordResetEmailComposer.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/PasswordResetEmailTemplate.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/SmtpEmailSender.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/SmtpOptions.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/UserInviteEmailComposer.cs create mode 100644 omsorgCore/src/OmsorgCore.Email/UserInviteEmailTemplate.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/DependencyInjection.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Events/DomainEventDispatcher.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Events/EmployeeCreatedEvent.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Events/IDomainEvent.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventDispatcher.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventHandler.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Events/PasswordResetRequestedEvent.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Events/UserInvitedEvent.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Handlers/EmployeeCreatedHandler.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Handlers/PasswordResetRequestedHandler.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/Handlers/UserInvitedHandler.cs create mode 100644 omsorgCore/src/OmsorgCore.Engine/OmsorgCore.Engine.csproj create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/ContractConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/InvoiceConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/LoginAttemptConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/OrderConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/PasswordResetCodeConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RoleConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RolePermissionConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/TimeEntryConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserPermissionOverrideConfiguration.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804183457_AddRefreshTokens.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804183457_AddRefreshTokens.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804192810_AddUserSecurityStamp.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804192810_AddUserSecurityStamp.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805092305_AddEmployeeContactFieldConstraints.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805092305_AddEmployeeContactFieldConstraints.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805094410_AddAuditableSoftDelete.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805094410_AddAuditableSoftDelete.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806160932_AddPasswordResetCodes.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806160932_AddPasswordResetCodes.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806175104_AddEmployeeStammdatenFields.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806175104_AddEmployeeStammdatenFields.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807071953_RenameEmployeeAddressToStructuredFields.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807071953_RenameEmployeeAddressToStructuredFields.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807092137_AddUserMustChangePassword.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807092137_AddUserMustChangePassword.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807101715_AddLoginAttemptsAndEmployeeAvatar.Designer.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807101715_AddLoginAttemptsAndEmployeeAvatar.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/OmsorgCoreDbContextModelSnapshot.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Persistence/OmsorgCoreDbContext.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Repositories/EmployeeRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Repositories/LoginAttemptRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Repositories/PasswordResetCodeRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Repositories/RefreshTokenRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Repositories/RoleRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Repositories/UserRepository.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/JwtOptions.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/JwtTokenGenerator.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/LoginAttemptOptions.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/LoginLockoutPolicy.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordHasher.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordResetCodeGenerator.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordResetOptions.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/RefreshTokenGenerator.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/RefreshTokenOptions.cs create mode 100644 omsorgCore/src/OmsorgCore.Infrastructure/Security/SeedOptions.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/GlobalUsings.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/OmsorgCore.Tests.csproj create mode 100644 omsorgCore/tests/OmsorgCore.Tests/Services/AuthServiceTests.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/Services/EmailTemplateRendererTests.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/Services/EmployeeServiceTests.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/Services/PasswordResetServiceTests.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/Services/PermissionServiceTests.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/Services/RoleServiceTests.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/Services/SessionAdminServiceTests.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/Services/UserServiceTests.cs create mode 100644 omsorgCore/tests/OmsorgCore.Tests/TestDoubles/FakeRepositories.cs create mode 100644 omsorgWeb/CLAUDE.md rename README.txt => omsorgWeb/README.txt (100%) rename {assets => omsorgWeb/assets}/einrichtungen.svg (100%) rename {assets => omsorgWeb/assets}/omsorg-bg-full.png (100%) rename {assets => omsorgWeb/assets}/omsorg-logo-new.png (100%) rename {assets => omsorgWeb/assets}/omsorg-logo.png (100%) rename {assets => omsorgWeb/assets}/omsorg-team-transparent.png (100%) rename {assets => omsorgWeb/assets}/omsorg-wordmark-new.png (100%) rename {assets => omsorgWeb/assets}/pflege-bewerbung.jpg (100%) rename {assets => omsorgWeb/assets}/pflege-einrichtungen.jpg (100%) rename {assets => omsorgWeb/assets}/pflege-hero.jpg (100%) rename {assets => omsorgWeb/assets}/pflege-training.jpg (100%) rename {assets => omsorgWeb/assets}/pflegekraefte.svg (100%) rename {assets => omsorgWeb/assets}/team.svg (100%) rename datenschutz.html => omsorgWeb/datenschutz.html (100%) rename impressum.html => omsorgWeb/impressum.html (100%) rename index.html => omsorgWeb/index.html (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/.htaccess (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/admin-action.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/delete-dokument.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/downloads-action.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/downloads-reorder.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/einsatzanweisung-action.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/fortbildung-material-action.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/fortbildung-material-reorder.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/logout.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/news-action.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/save-dienstplan.php (100%) create mode 100644 omsorgWeb/mitarbeiter-app-legacy/actions/save-profile.php rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/submit-abwesenheitsantrag.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/submit-benefitsantrag.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/submit-einsatzbewertung.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/submit-fortbildungsantrag.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/submit-stundennachweis.php (100%) create mode 100644 omsorgWeb/mitarbeiter-app-legacy/actions/submit-urlaubsantrag.php rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/submit-werben.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/upload-avatar.php (56%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/actions/upload-dokument.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/app.css (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/assets/Liberty.jpeg (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/assets/omsorg-bg-full.png (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/assets/omsorg-logo-new.png (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/assets/omsorg-team-transparent.png (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/assets/omsorg-wordmark-new.png (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/kindelserif/1001fonts-kindelserif-eula.txt (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/kindelserif/KindelSerif_PersonalUse.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyBold Italic.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyBold.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyExtrabold Italic.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyExtrabold.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyHeavy Italic.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyHeavy.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyItalic.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyLight Italic.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyLight.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyMedium Italic.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyMedium.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyRegular.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbySemibold Italic.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbySemibold.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyThin Italic.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/font/visby/VisbyThin.otf (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/index.php (63%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/lib/.htaccess (100%) create mode 100644 omsorgWeb/mitarbeiter-app-legacy/lib/auth.php rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/lib/config.secret.example.php (58%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/lib/db.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/lib/layout.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/lib/mail.php (100%) create mode 100644 omsorgWeb/mitarbeiter-app-legacy/lib/omsorgCoreClient.php rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/lib/upload.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/manifest.webmanifest (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/migrations/.htaccess (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/migrations/001_initial_schema.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/migrations/002_avatar_column.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/migrations/003_einsatzanweisung.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/migrations/004_login_attempts.php (100%) create mode 100644 omsorgWeb/mitarbeiter-app-legacy/migrations/005_urlaubsantrag_anhang.php create mode 100644 omsorgWeb/mitarbeiter-app-legacy/migrations/006_omsorgcore_id_mapping.php rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/abwesenheitsantrag.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/admin-dienstplan.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/admin.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/benefitsantrag.php (100%) create mode 100644 omsorgWeb/mitarbeiter-app-legacy/pages/change-password.php rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/dashboard.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/dienstplan.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/dokument-serve.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/dokumentenarchiv.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/download-serve.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/downloads.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/einsatzanweisung-serve.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/einsatzanweisung.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/einsatzbewertung.php (100%) create mode 100644 omsorgWeb/mitarbeiter-app-legacy/pages/forgot-password.php rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/fortbildung-serve.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/fortbildungsantrag.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/settings.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/stundennachweis.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/upload-serve.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/urlaubsantrag.php (96%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/pages/werben.php (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/service-worker.js (100%) rename {mitarbeiter-app => omsorgWeb/mitarbeiter-app-legacy}/setup.php (100%) rename send-form.php => omsorgWeb/send-form.php (100%) rename send-status-template.php => omsorgWeb/send-status-template.php (100%) rename style.css => omsorgWeb/style.css (100%) create mode 100644 omsorgapp/.gitignore create mode 100644 omsorgapp/CLAUDE.md create mode 100644 omsorgapp/README.md create mode 100644 omsorgapp/docs/OMSORG-BLUEPRINT.md create mode 100644 omsorgapp/electron/backend/authClient.cjs create mode 100644 omsorgapp/electron/backend/config.cjs create mode 100644 omsorgapp/electron/backend/employeesClient.cjs create mode 100644 omsorgapp/electron/backend/httpClient.cjs create mode 100644 omsorgapp/electron/backend/usersClient.cjs create mode 100644 omsorgapp/electron/main.cjs create mode 100644 omsorgapp/electron/preload.cjs create mode 100644 omsorgapp/index.html create mode 100644 omsorgapp/package-lock.json create mode 100644 omsorgapp/package.json create mode 100644 omsorgapp/src/app/AuthContext.jsx create mode 100644 omsorgapp/src/app/app.jsx create mode 100644 omsorgapp/src/app/navPermissions.js create mode 100644 omsorgapp/src/assets/omsorg_logo.png create mode 100644 omsorgapp/src/components/Header.jsx create mode 100644 omsorgapp/src/components/OmsorgCard.jsx create mode 100644 omsorgapp/src/components/Sidebar.jsx create mode 100644 omsorgapp/src/components/ui/OmsorgBadge.jsx create mode 100644 omsorgapp/src/components/ui/OmsorgButton.jsx create mode 100644 omsorgapp/src/components/ui/OmsorgPagination.jsx create mode 100644 omsorgapp/src/components/ui/OmsorgStatCard.jsx create mode 100644 omsorgapp/src/layouts/AppLayout.jsx create mode 100644 omsorgapp/src/main.jsx create mode 100644 omsorgapp/src/main.legacy.jsx create mode 100644 omsorgapp/src/modules/auth/ChangePasswordScreen.jsx create mode 100644 omsorgapp/src/modules/auth/ForgotPasswordNewPasswordPage.jsx create mode 100644 omsorgapp/src/modules/auth/ForgotPasswordPinPage.jsx create mode 100644 omsorgapp/src/modules/auth/ForgotPasswordUsernamePage.jsx create mode 100644 omsorgapp/src/modules/auth/LoginPage.jsx create mode 100644 omsorgapp/src/modules/debug/DebugSessionsPage.jsx create mode 100644 omsorgapp/src/modules/employees/CreateEmployeeDialog.jsx create mode 100644 omsorgapp/src/modules/employees/CreateUserAccountDialog.jsx create mode 100644 omsorgapp/src/modules/employees/EditEmployeeDialog.jsx create mode 100644 omsorgapp/src/modules/employees/EmployeeDetailPanel.jsx create mode 100644 omsorgapp/src/modules/employees/EmployeeForm.jsx create mode 100644 omsorgapp/src/modules/employees/EmployeeTabs.jsx create mode 100644 omsorgapp/src/modules/employees/EmployeesPage.jsx create mode 100644 omsorgapp/src/modules/home/ContractWidget.jsx create mode 100644 omsorgapp/src/modules/home/HomePage.jsx create mode 100644 omsorgapp/src/modules/home/HomeStats.jsx create mode 100644 omsorgapp/src/style.backup.css create mode 100644 omsorgapp/src/style.css diff --git a/.gitignore b/.gitignore index 0a832c9..f64c69e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,13 @@ # Config with credentials -mitarbeiter-app/config.php -mitarbeiter-app/config.secret.php +**/mitarbeiter-app*/lib/config.php +**/mitarbeiter-app*/lib/config.secret.php # Uploaded files -mitarbeiter-app/uploads/ -mitarbeiter-app/downloads/ -mitarbeiter-app/fortbildung-materials/ -mitarbeiter-app/assets/avatars/ -mitarbeiter-app/data/ +**/mitarbeiter-app*/uploads/ +**/mitarbeiter-app*/downloads/ +**/mitarbeiter-app*/fortbildung-materials/ +**/mitarbeiter-app*/assets/avatars/ +**/mitarbeiter-app*/data/ # OS / editor .DS_Store @@ -19,3 +19,7 @@ Thumbs.db # Logs *.log + +# .NET (omsorgCore) +omsorgCore/**/bin/ +omsorgCore/**/obj/ diff --git a/CLAUDE.md b/CLAUDE.md index 8e775c7..fc49895 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,116 +1,51 @@ -# CLAUDE.md +# CLAUDE.md — OMSORG Business Controls Pro (Monorepo-Root) -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Diese Datei gilt für das gesamte Repository. Jedes Teilprojekt hat zusätzlich eine eigene `CLAUDE.md` mit projektspezifischen Details — bei Arbeit in einem Unterordner gilt dessen `CLAUDE.md` zusätzlich zu dieser hier. -## Project Overview +## Was das hier ist -This is the OMSORG website plus **OMSORG Connect**, an internal employee web app ("Mitarbeiter-App") for a German nursing care company. There is no build system — everything is plain PHP, HTML, CSS, and vanilla JS deployed directly to a web server (`htdocs`). +Ein Monorepo für die OMSORG-Plattform, einer Software für ein Pflege-Zeitarbeitsunternehmen. Vier Ebenen, drei Teilprojekte (Core+Engine bilden ein Backend, siehe unten): -## Deployment +| Verzeichnis | Rolle | Stack | Ist-Stand | +|---|---|---|---| +| `omsorgWeb/` | Öffentliche Website + **OMSORG Connect** (Mitarbeiter-App für Außendienst) | PHP, MySQL, vanilla JS, PWA | ✅ produktiv im Einsatz | +| `omsorgapp/` | **OMSORG Desktop** — Software für Büromitarbeiter (Sabina, Malik, Sabrina, Sascha) | Electron + React + Vite | 🔶 frühes Grundgerüst (Release 0.1.1), lokale JSON-Datenhaltung | +| `omsorgCore/` | **OMSORG Backend** — gemeinsame Datenbasis + Automatisierung (Core + Engine als eine Komponente) | C# / .NET 8, ASP.NET Core (Controller), PostgreSQL/EF Core, JWT-Auth | 🔶 Grundgerüst steht (Domain/Application/Infrastructure/Engine/Api, Rechtesystem, Auth, Employee-Endpoint end-to-end verifiziert); noch keine Anbindung von `omsorgWeb`/`omsorgapp`, DB-Migration noch nicht gegen echte Postgres getestet | -Upload the full directory contents to the server's `htdocs` folder and overwrite existing files. After deploying, clear the browser cache or test in an incognito window. +Leitdokumente: +- `omsorg.md` — Kurzvision (Rohnotizen) +- `omsorgapp/docs/OMSORG-BLUEPRINT.md` — vollständige Vision/Architektur (20 Kapitel, verbindliche Arbeitsgrundlage) +- `REQUIREMENTS.md` — daraus abgeleitete, nummerierte funktionale/nicht-funktionale Anforderungen (FR-IDs, NFR-IDs) mit Ist-Status je Requirement -There are no build steps, package managers, or test runners. +**Immer zuerst `REQUIREMENTS.md` konsultieren**, bevor an einem Modul gearbeitet wird — dort steht, was bereits existiert (✅), teilweise existiert (🔶) oder noch offen ist (⬜), inklusive Verweis auf die konkreten Dateien. -## Architecture +## Zentrales Architekturprinzip -### Public website (`/`) -Static HTML with a contact form: -- `index.html` — main landing page -- `send-form.php` — handles the public contact form, sends email via PHP `mail()` -- `send-status-template.php` — reusable status page template for form results -- `datenschutz.html`, `impressum.html` — legal pages +**Jede Information wird nur einmal gespeichert.** Die sechs Core-Objekte (Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung) leben in genau einer Datenquelle — `omsorgCore` — und werden von Desktop, Connect und allen Auswertungen gemeinsam genutzt. Aktuell ist das **noch nicht der Fall**: `omsorgWeb` hat eine eigene MySQL-DB, `omsorgapp` eine eigene lokale JSON-DB. Das ist der bekannte Hauptmangel der aktuellen Architektur (siehe `REQUIREMENTS.md` Abschnitt 2 und 10) — keine neue Insellösung schaffen, sondern auf die Zusammenführung hinarbeiten. -### Employee app (`/mitarbeiter-app/`) -A session-based PHP app with no framework, backed by a **MySQL database** (PDO). +OMSORG Core (Datenschicht) und OMSORG Engine (ereignisgesteuerte Automatisierung) sind **eine** Backend-Komponente mit zwei internen Schichten, kein separates Deployable für die Engine. -#### Entry point -- `index.php` — login page. Redirects to `pages/dashboard.php` if already logged in. Includes rate limiting: 5 failed attempts trigger a 10-minute lockout. -- `setup.php` — one-time setup script (run once after first deploy to bootstrap the DB) +## Rollen (gelten repoweit) -#### Library (`lib/`) -- `auth.php` — included at the top of every protected page. Provides `require_login()`, `require_admin()`, `is_logged_in()`, `is_admin()`, `current_user()`, `current_name()`, `current_username()`, `csrf_token()`, `csrf_field()`, `verify_csrf()`, `e()`. Users are stored in the `users` DB table. Login is case-insensitive on username. -- `db.php` — provides `db(): PDO` (singleton). Reads credentials from `config.php`, runs pending migrations on first connection, and sets `PDO::ERRMODE_EXCEPTION`. -- `config.php` — returns array with MySQL DSN/credentials, SMTP settings, and mail recipient addresses. Import with `$config = require __DIR__ . '/config.php';`. -- `layout.php` — shared sidebar layout. Call `layout_start($title, $current_page)` and `layout_end()` to wrap page content. Renders the sidebar nav, user avatar, and injects the service worker. -- `mail.php` — provides `smtp_send($cfg, $to, $subject, $body, $attach = [])`. Raw SMTP implementation (no PHPMailer), supports SSL (port 465) and STARTTLS (port 587), and optional file attachments. +| Rolle | Zugriff | +|---|---| +| Sabina, Malik (Geschäftsführung) | Vollzugriff, inkl. Gewinn/Margen | +| Sabrina (Disposition/Buchhaltung) | fast alles außer vollständigem Gewinn/Gesamtmargen | +| Sascha (Recruiting) | Recruiting/CRM, kein Zugriff auf Rechnungen/Zahlungen/Gewinn/Controlling | +| Außendienst | nur über OMSORG Connect, nur eigene Daten | -#### Pages (`pages/`) -Each page is a standalone PHP file using `layout_start`/`layout_end`: -- `dashboard.php` — main landing page after login; shows news and quick links -- `stundennachweis.php` — upload monthly timesheet (PDF/image) -- `urlaubsantrag.php` — vacation request form -- `abwesenheitsantrag.php` — absence request form (sick leave, etc.) -- `benefitsantrag.php` — employee benefits request -- `fortbildungsantrag.php` — training request with optional file upload -- `einsatzbewertung.php` — rate a deployment/assignment -- `einsatzanweisung.php` — view/download personal assignment instructions (PDF) -- `dokumentenarchiv.php` — personal document archive (upload/view own documents) -- `downloads.php` — company-wide file downloads (admin-managed) -- `dienstplan.php` — view personal shift schedule -- `werben.php` — refer a new employee -- `settings.php` — profile settings: change password, upload avatar -- `admin.php` — admin-only: tabs for Anträge, Nutzer, Dienstplan, Downloads, Fortbildungsmaterial, Stundennachweis, Bewertungen, News, Einsatzanweisung, Login-Versuche (failed login attempts: lists recent attempts, shows currently locked IPs, and clears the `login_attempts` table) -- `admin-dienstplan.php` — admin shift planner view -- Serve pages (`*-serve.php`, `dokument-serve.php`, `download-serve.php`, etc.) — stream protected files from `uploads/`, `downloads/`, `fortbildung-materials/` with auth check +Jede neue Funktion muss serverseitig gegen diese Rechte prüfen, nicht nur im UI verstecken. -#### Actions (`actions/`) -POST-only endpoints, each does one thing and redirects back: -- `submit-urlaubsantrag.php`, `submit-abwesenheitsantrag.php`, `submit-benefitsantrag.php`, `submit-fortbildungsantrag.php`, `submit-stundennachweis.php`, `submit-einsatzbewertung.php`, `submit-werben.php` — insert into the matching `requests_*` DB table, send notification email via `smtp_send()` -- `admin-action.php` — admin status updates (accept/reject requests), user management (add/delete/reset password), news CRUD -- `save-dienstplan.php` — admin saves shift entries -- `downloads-action.php`, `downloads-reorder.php` — admin manages downloadable files -- `fortbildung-material-action.php`, `fortbildung-material-reorder.php` — admin manages training materials -- `einsatzanweisung-action.php` — admin uploads/assigns personal instruction PDFs -- `upload-dokument.php`, `delete-dokument.php` — user document archive management -- `upload-avatar.php` — user uploads profile picture (stored in `assets/avatars/`) -- `save-profile.php` — user changes own password -- `logout.php` — destroys session, redirects to `../index.php` +## Arbeitsweise -#### Frontend -- `app.css` — all styles (no framework) -- `manifest.webmanifest` + `service-worker.js` — PWA support ("Add to Home Screen") +- Kleine, überprüfbare Schritte: eine Datei/Funktion bearbeiten, testen, Fehler beheben, committen. +- Keine doppelt gepflegten Daten, keine isolierten Insellösungen, wiederverwendbare Komponenten. +- Sicherheit und Datenschutz (DSGVO) sind Grundvoraussetzung, keine spätere Zusatzfunktion. +- Branding/Design vom fachlichen Kern trennen (langfristiges White-Label-Ziel, siehe Blueprint Kap. 14) — aktuell nicht umgesetzt, aber bei neuem Code nicht zusätzlich verfestigen. -### Database schema (MySQL) -Managed via migrations in `migrations/`. Key tables: -- `users` — id, username, name, role, password_hash, active, created_at, email, telefon, avatar -- `requests_urlaubsantrag` — vacation requests (von, bis, vertretung, nachricht, status, admin_note) -- `requests_abwesenheitsantrag` — absence requests (von, bis, grund, vertretung, nachricht, status, admin_note) -- `requests_benefitsantrag` — benefits requests -- `requests_fortbildungsantrag` — training requests (with optional file upload) -- `requests_stundennachweis` — timesheet uploads (monat, filename) -- `requests_werben` — employee referrals -- `dienstplan` — shift schedule (user_id, date, schicht; UNIQUE on user_id+date) -- `downloads` — company download files (title, filename, sort_order) -- `fortbildung_materials` — training material files (title, filename, sort_order) -- `dokumente` — user personal document archive (user_id, kategorie, filename) -- `einsatzbewertungen` — deployment ratings -- `einsatzanweisung` — one PDF per user (UNIQUE on user_id) -- `news` — company news posts (title, text, date) -- `schema_migrations` — tracks applied migrations +## Wo was steht -### Migration system -`db.php` runs `_run_migrations()` on every connection. Migrations are PHP files in `migrations/` returning `['description' => ..., 'up' => [...SQL...]]`. They are applied in filename order and tracked in `schema_migrations`. Already-existing DBs without the migrations table are stamped as fully applied on first run. - -### File storage -Uploaded files are stored outside webroot or protected by `.htaccess`: -- `uploads/` — user form attachments (stundennachweis, fortbildung, einsatzanweisung). Filename pattern: `{date}_{username}_{type}_{randomhex}.{ext}` -- `downloads/` — admin-managed company downloads (hashed filenames) -- `fortbildung-materials/` — admin-managed training materials (hashed filenames) -- `assets/avatars/` — user profile pictures (`{username}_{randomhex}.{ext}`) - -Allowed upload types: pdf, doc, docx, jpg, jpeg, png. Max size: 12 MB. - -### Security conventions -- `lib/` is blocked from direct HTTP access via `lib/.htaccess` -- `uploads/`, `downloads/`, `fortbildung-materials/`, `data/`, `migrations/` all have `.htaccess` denying direct access; files are served only through `*-serve.php` pages with auth checks -- All output uses `e()` (alias for `htmlspecialchars`) to prevent XSS -- Every POST form includes a CSRF token (`csrf_field()` in form, `verify_csrf()` in action) -- Login rate-limiting: 5 failures → 10-minute session lockout -- Uploads are renamed to random hex filenames before storage - -### Email (configured in `config.php`) -- SMTP via `smtp_send()` in `lib/mail.php`; credentials in `config.php` -- `mail_info` → `info@omsorg-pflegedienste.de` (Geschäftsführung) -- `mail_sabrina` → `s.berggoetz@omsorg-pflegedienste.de` (Disposition) -- `mail_from` → `no-reply@omsorg-pflegedienste.de` +- Detaillierte technische Konventionen je Teilprojekt → `omsorgWeb/CLAUDE.md`, `omsorgapp/CLAUDE.md`, `omsorgCore/CLAUDE.md`. +- Requirements/Akzeptanzkriterien → `REQUIREMENTS.md`. +- Produktvision/Roadmap → `omsorgapp/docs/OMSORG-BLUEPRINT.md`. +- Backend-Konfigurationswerte (was einstellbar ist, Defaults, Env-Var-Overrides) → `omsorgCore/CONFIGURATION.md`. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..82a921e --- /dev/null +++ b/REQUIREMENTS.md @@ -0,0 +1,261 @@ +# OMSORG Business Controls Pro — Requirements-Dokument (SRS) + +Status: Entwurf 1.0 +Basis: `omsorg.md` (Kurzvision) + `omsorgapp/docs/OMSORG-BLUEPRINT.md` (Vision/Architektur, verbindliche Arbeitsgrundlage) +Zweck: Aus der Vision konkrete, testbare Anforderungen ableiten — Grundlage für Entwicklung, Abnahme und Tests. + +Dieses Dokument dupliziert den Blueprint nicht. Es referenziert ihn (`[Blueprint Kap. X]`) und übersetzt ihn in nummerierte Requirements mit Akzeptanzkriterium und Ist-Status. + +Status-Legende: ✅ vorhanden · 🔶 teilweise vorhanden · ⬜ offen + +--- + +## 1. Einleitung + +### 1.1 Zweck +Dieses Dokument definiert die funktionalen und nicht-funktionalen Anforderungen an OMSORG Business Controls Pro — die Plattform, mit der Omsorg (Pflege-Zeitarbeit) ihren gesamten Arbeitsalltag abwickelt. + +### 1.2 Geltungsbereich +Umfasst alle drei Plattform-Ebenen: OMSORG Desktop, OMSORG Connect, OMSORG Backend (Core + Engine) [Blueprint Kap. 4]. + +> **Architektur-Entscheidung:** Der Blueprint beschreibt Core und Engine als zwei separate Ebenen (Kap. 19/20). In diesem Requirements-Dokument werden sie als **eine Backend-Komponente** mit zwei internen Schichten geführt: Datenschicht (Core) und Event-Schicht (Engine), betrieben im selben Prozess/Service. Grund: Die Engine liest und schreibt permanent auf denselben Core-Objekten; eine Trennung in zwei Deployables würde nur Netzwerk-Roundtrips und Konsistenzrisiken erzeugen, ohne dass ein reales Skalierungsproblem das rechtfertigt. Die inhaltliche Beschreibung aus dem Blueprint bleibt unverändert — nur die Einordnung als eine statt zwei Plattform-Ebenen ändert sich. + +### 1.3 Begriffe +| Begriff | Bedeutung | +|---|---| +| Mitarbeiter | Person im Personalbestand (Büro oder Außendienst) | +| Einrichtung | Kunde (Pflegeeinrichtung), CRM-Objekt | +| Vertrag | Verbindliche Vereinbarung (Arbeitsvertrag, Rahmenvertrag, Einsatzvertrag, ...) | +| Auftrag | Personalbedarf einer Einrichtung | +| Einsatz | Konkrete Zuweisung eines Mitarbeiters zu einem Auftrag/Zeitraum | +| Zeiterfassung | Dokumentierte, tatsächlich geleistete Arbeitszeit | +| Tätigkeitsnachweis | Nachweis-Dokument zu geleisteter Zeit (ggf. mit Unterschrift) | +| Rechnung | Aus freigegebener Zeiterfassung + Konditionen erzeugtes Abrechnungsdokument | +| Engine | Event-Schicht des OMSORG Backends: ereignisgesteuerte Automatisierung ohne eigene UI, im selben Prozess wie die Datenschicht (Core) [Blueprint Kap. 20] | + +--- + +## 2. Systemüberblick & Ist-Stand + +| Ebene | Beschreibung | Ist-Stand im Repo | +|---|---|---| +| **OMSORG Desktop** | Vollständige Unternehmensmodule für Büromitarbeiter | 🔶 `omsorgapp/` — Electron/React, Release 0.1.1. Vorhanden: `HomePage`, `HomeStats`, `ContractWidget`, `EmployeesPage` mit Tabs/Detailpanel. Lokale JSON-DB (`~/Documents/Omsorg Business Controls Pro/database/omsorg-local-db.json`), noch keine SQLite/Server-Anbindung. | +| **OMSORG Connect** | Mobile/Web-App für Außendienst | ✅ `omsorgWeb/mitarbeiter-app/` — PHP/MySQL, produktiv als PWA (Manifest + Service Worker). Deckt bereits Zeiterfassung/Stundennachweis, Urlaub, Abwesenheit, Fortbildung, Dokumente, News, Benefits, Werben, Einsatzanweisung, Dienstplan, Bewertungen ab. | +| **OMSORG Backend (Core + Engine)** | Eine Backend-Komponente, zwei interne Schichten: **Datenschicht (Core)** — gemeinsame Datenbasis, 6 Objekte; **Event-Schicht (Engine)** — Ereignis→Aktion-Automatisierung ohne eigene UI, arbeitet auf denselben Objekten der Datenschicht | 🔶 `omsorgCore/` — Grundgerüst steht (C#/.NET 8, ASP.NET Core Controller, EF Core/PostgreSQL, JWT-Auth, Rollen+Permission-Override-Rechtesystem, In-Process-Event-Dispatcher). Bisher nur `Employee` mit vollem Repository/Service/Controller; die anderen 5 Core-Objekte existieren als Domain-Entitäten, aber ohne Endpunkte. `omsorgapp` ist für Mitarbeiter jetzt **echt angebunden** (Login/Refresh sowie Employees-CRUD laufen über `omsorgCore`, keine lokale JSON-Datenhaltung mehr für dieses Modul). **Noch keine Anbindung** von `omsorgWeb` (MySQL) an dieses Backend, und die übrigen `omsorgapp`-Fachmodule (Kunden, Disposition, ...) nutzen weiterhin die lokale JSON-DB — die Insellösungen bestehen dort technisch weiter, bis diese Migration erfolgt. DB-Migration wurde noch nicht gegen eine echte PostgreSQL-Instanz verifiziert. Details: `omsorgCore/CLAUDE.md`. | + +**Kernrisiko für die Roadmap:** Solange das Backend (Core + Engine) nicht existiert, sind Connect (MySQL) und Desktop (JSON) zwei Insellösungen — genau das, was Blueprint Kap. 19.9 ausschließt. Phase 1 der Roadmap muss dies zuerst auflösen (siehe Abschnitt 10). + +--- + +## 3. Akteure & Rollen + +| Rolle | Personen | Zugriffsniveau [Blueprint Kap. 6] | +|---|---|---| +| Geschäftsführung | Sabina, Malik | Vollzugriff: alle Module, Finanzdaten, Gewinn, Margen, Benutzerverwaltung | +| Disposition/Buchhaltung | Sabrina | Mitarbeiter, Einrichtungen, Aufträge/Einsätze, Zeiterfassung, Rechnungen, Zahlungen, Recruiting/CRM — **kein** vollständiger Gewinn/Gesamtmargen | +| Recruiting | Sascha | Recruiting, Bewerber, Mitarbeiterakten (soweit nötig), Einrichtungs-Leads, Akquise, Marketing — **kein** Zugriff auf Rechnungen, Zahlungen, Gewinn, Margen, Controlling | +| Außendienst | alle Pflegekräfte | Nur OMSORG Connect, nur eigene Daten | +| Zukünftige Büromitarbeiter | — | Rolle als Vorlage + individuell vergebene/entzogene Rechte (sehen/anlegen/bearbeiten/löschen/exportieren/freigeben je Modul) | + +--- + +## 4. Funktionale Anforderungen (FR) + +### 4.1 Dashboard / Morning Briefing / Tagesabschluss + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-DASH-1 | Beim Öffnen zeigt das Dashboard neue Outlook-Mails, Krankmeldungen, fehlende Tätigkeitsnachweise, Vertragsverlängerungen, offene Rechnungen, Kaltaquise-Termine, neue Mitarbeiter, Auslastung [Blueprint 2.1] | alle Büro-Rollen | Jede Kategorie ist als eigene Kachel/Liste sichtbar und führt per Klick zur passenden Aktion | 🔶 (`HomePage`/`HomeStats`/`ContractWidget` in `omsorgapp` zeigen einen Teil; Outlook, Krankmeldungen, Kaltaquise fehlen) | +| FR-DASH-2 | Gewinn-Kachel ist ausschließlich für Sabina/Malik sichtbar [Blueprint 6.1] | Geschäftsführung | Anderer Rolle wird die Kachel serverseitig nicht ausgeliefert, nicht nur UI-verborgen | ⬜ | +| FR-DASH-3 | Tagesabschluss-Ansicht zeigt „Heute erledigt" vs. „Noch offen" [Blueprint 2.3] | alle Büro-Rollen | Am Ende des Tages ist eine konsolidierte Liste abrufbar | ⬜ | +| FR-DASH-4 | Jede Dashboard-Information führt direkt zur zugehörigen Aktion (z. B. Vertrag öffnen, Mitarbeiter zurückrufen) | alle Büro-Rollen | Klick navigiert ins jeweilige Modul mit vorausgewähltem Datensatz | ⬜ | + +### 4.2 Mitarbeiter (Personalakte) + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-MA-1 | Stammdaten erfassen (Name, Geburtsdatum, Adresse, Kontakt, Notfallkontakt, Beschäftigungsart, Qualifikation, Ein-/Austritt, Status) [Blueprint 19.1] | Sabina, Malik, Sabrina | Datensatz anlegen/bearbeiten mit Pflichtfeldern; Validierung verhindert unvollständige Sätze | ✅ (alle Felder in `Employee`-Entity + `EmployeeForm` in `omsorgapp` vorhanden; Validierung serverseitig in `EmployeesController` (Pflichtfelder, Längen, Ein-/Austrittslogik, Beschäftigungsart-Allowlist) und clientseitig verdrahtet; `omsorgapp` spricht für Mitarbeiter jetzt über `employeesClient.cjs` echt gegen `omsorgCore`, keine JSON-lokale Persistenz mehr für dieses Modul — Abgleich mit Connect/`omsorgWeb` weiterhin offen, siehe FR-CORE-1) | +| FR-MA-2 | Arbeitsvertragsdaten (Beginn/Ende, Arbeitszeit, Stundenlohn, Zuschläge, Überstunden, Urlaubsanspruch, Probezeit) [Blueprint 19.1] | Sabina, Malik, Sabrina | Vertragsfelder je Mitarbeiter editierbar, Historie bei Änderung nachvollziehbar | ⬜ | +| FR-MA-3 | Dokumente, Qualifikationen, Fortbildungen, Führerschein, Gesundheitsnachweise, Notizen, Historie je Mitarbeiter | Sabina, Malik, Sabrina | Upload/Anzeige je Kategorie, Zugriffsprotokoll | 🔶 (Dokumentenarchiv existiert bereits in Connect: `pages/dokumentenarchiv.php`, `actions/upload-dokument.php`; im Desktop-Modul fehlt es) | +| FR-MA-4 | Eintrittsdatum löst Dashboard-Hinweis auf bevorstehenden Mitarbeiterstart aus [Blueprint 7] | alle Büro-Rollen | X Tage vor Eintritt erscheint Engine-Hinweis im Dashboard | ⬜ (abhängig von Engine, siehe 4.10) | +| FR-MA-5 | Bei Büromitarbeitern kann der Mitarbeiterdatensatz mit einem Benutzerkonto + individuellen Rechten verknüpft werden [Blueprint 19.1] | Sabina, Malik | Rechteliste pro Modul (sehen/anlegen/bearbeiten/löschen/exportieren/freigeben) editierbar | 🔶 (`users`-Tabelle mit Rolle in `omsorgWeb/mitarbeiter-app` vorhanden, aber nur grobe Rolle, keine granularen Einzelrechte) | +| FR-MA-6 | Außendienstmitarbeiter sehen ausschließlich eigene Daten [Blueprint 4.2] | Außendienst | Query/Route liefert nur Datensätze mit `user_id = aktueller Nutzer` | ✅ (Session-basierter Zugriff in `omsorgWeb/mitarbeiter-app`, z. B. `pages/urlaubsantrag.php`) | + +### 4.3 Einrichtungen & CRM + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-EIN-1 | Stammdaten (Name, Art, Adresse, Rechnungsadresse, Telefon, E-Mail, Website, Status) [Blueprint 19.2] | Sabina, Malik, Sabrina, Sascha (Leads) | Datensatz anlegen/bearbeiten | ⬜ | +| FR-EIN-2 | Mehrere Ansprechpartner je Einrichtung mit Funktion, Abteilung, Kontaktwegen, Notizen | Sabina, Malik, Sabrina, Sascha | Liste von Ansprechpartnern editierbar, mind. 1:n-Beziehung | ⬜ | +| FR-EIN-3 | CRM-Status-Pipeline: Lead → kontaktiert → kein Bedarf → Wiedervorlage → Interesse → Angebot → Kunde → Bestandskunde [Blueprint, omsorg.md] | Sascha, Sabrina | Statuswechsel wird protokolliert; bei „kein Bedarf" wird automatisch Wiedervorlage in 14 Tagen erzeugt | ⬜ | +| FR-EIN-4 | Konditionen (Verrechnungssatz, Zuschläge, Fahrtkosten, Mindeststunden, Zahlungsziel etc.) je Einrichtung [Blueprint 19.2] | Sabina, Malik, Sabrina | Konditionssatz ist Grundlage für Rechnungserstellung (siehe FR-RE-1) | ⬜ | +| FR-EIN-5 | Verknüpfte Historie: Verträge, Aufträge, zugewiesene Mitarbeiter, Nachweise, Rechnungen, Zahlungen, Mahnungen, Kommunikation | alle Büro-Rollen (rechteabhängig) | Einrichtungsakte zeigt konsolidierte Historie ohne Datenduplizierung | ⬜ | + +### 4.4 Einsatzmanagement (Auftrag → Einsatz → Zuweisung) + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-EM-1 | Auftrag erfassen (Einrichtung, Ansprechpartner, Qualifikation, Zeitraum, Schichtart, Anzahl Mitarbeiter, Konditionen, Priorität) [Blueprint 19.4] | Sabrina | Auftragsdatensatz mit Pflichtfeldern anlegbar | ⬜ | +| FR-EM-2 | Auftragsstatus-Pipeline: Anfrage → Prüfung → offen → teilweise besetzt → vollständig besetzt → aktiv → abgeschlossen/storniert [Blueprint 19.4] | Sabrina | Statuswechsel nur in zulässiger Reihenfolge, sichtbar im Dashboard | ⬜ | +| FR-EM-3 | Mitarbeiterzuweisung prüft Qualifikation, Verfügbarkeit, Arbeitszeit, Abwesenheiten, Überschneidungen, Vertragsbedingungen [Blueprint 19.4] | Sabrina | System verhindert/warnt bei Konflikten vor Zuweisung | ⬜ | +| FR-EM-4 | Nach Zuweisung erhält Mitarbeiter automatisch Einsatzanweisung über OMSORG Connect [Blueprint 19.4] | System → Außendienst | Einsatzanweisung erscheint in Connect ohne manuellen Zusatzschritt | 🔶 (Connect hat bereits `pages/einsatzanweisung.php` inkl. Admin-Upload `actions/einsatzanweisung-action.php`; automatische Erzeugung aus Zuweisung fehlt, da Aufträge/Zuweisung noch nicht existieren) | +| FR-EM-5 | Krankmeldung löst Ersatzbesetzungs-Workflow aus [omsorg.md, Blueprint 20.2] | System, Sabrina | Bei Krankmeldung erscheint Einsatz als "muss neu besetzt werden" inkl. Vorschlägen | ⬜ | + +### 4.5 Zeiterfassung & Tätigkeitsnachweise + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-ZE-1 | Erfassung von Beginn, Pause, Ende, Einrichtung, Auftrag, Nacht-/Wochenend-/Feiertagsstunden [Blueprint 19.5] | Außendienst | Eintrag pro Schicht mit Pflichtfeldern | 🔶 (aktuell nur monatlicher Upload via `pages/stundennachweis.php`/`actions/submit-stundennachweis.php`, keine strukturierte Beginn/Pause/Ende-Erfassung pro Schicht) | +| FR-ZE-2 | Statuspipeline: Entwurf → eingereicht → Prüfung → Rückfrage → freigegeben → abgerechnet [Blueprint 19.5] | Außendienst, Sabrina | Statuswechsel sichtbar für beide Seiten, Rückfrage möglich | 🔶 (aktueller Status ist binär offen/geprüft über Admin-Anträge, kein granularer Workflow) | +| FR-ZE-3 | Nur freigegebene Zeiten dürfen in Rechnungsstellung einfließen [Blueprint 19.5, 19.8] | System | Rechnungslauf ignoriert nicht-freigegebene Datensätze; Verstoß ist technisch unmöglich, nicht nur UI-Regel | ⬜ | +| FR-ZE-4 | Optionale Unterschrift/Bestätigung der Einrichtung, Upload als Foto/PDF | Außendienst | Upload-Feld vorhanden, an Zeiterfassungssatz gekoppelt | 🔶 (Upload existiert für Monatsnachweis, nicht pro Einzel-Einsatz) | + +### 4.6 Rechnungen & Zahlungsüberwachung + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-RE-1 | Automatische Rechnungserstellung aus freigegebener Zeit + Konditionen + Zuschlägen [Blueprint 19.6] | System, Sabrina (Freigabe) | Rechnungsentwurf wird ohne manuelle Neuerfassung generiert | ⬜ | +| FR-RE-2 | Statuspipeline: Entwurf → Prüfung → freigegeben → versendet → offen → bezahlt (alternativ überfällig → Mahnung → bezahlt) [Blueprint 19.6] | Sabrina, Sabina/Malik (Freigabe) | Statuswechsel protokolliert, keine übersprungenen Zwischenschritte | ⬜ | +| FR-RE-3 | Zahlungsziel 14 Tage wird von der Engine überwacht; bei Überschreitung Dashboard-Warnung + Mahnentwurf [Blueprint 19.6, 20.2] | System → Sabrina | Nach 14 Tagen ohne Zahlungseingang erscheint automatisch Mahnentwurf | ⬜ | +| FR-RE-4 | Rechnungs-PDF-Erzeugung und Versand, Dokumentation in Mahnhistorie | Sabrina | PDF entspricht Rechnungsdaten; Versand/Historie nachvollziehbar | ⬜ | + +### 4.7 Recruiting & Akquise + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-REC-1 | Bewerberpipeline: Interessent → Bewerbung → Erstkontakt → Vorstellung → Unterlagen → Angebot → Einstellung → geplanter Eintritt [Blueprint 3.2] | Sascha | Bewerberdatensatz mit Statuswechsel | ⬜ | +| FR-REC-2 | Werbung/Empfehlung: Kanäle Indeed, Facebook, Instagram, Mitarbeiterempfehlung erfassbar [omsorg.md] | Sascha | Herkunft je Bewerber taggable, auswertbar | 🔶 (`pages/werben.php`/`actions/submit-werben.php` erfasst Mitarbeiterempfehlungen bereits; Kanaltracking für externe Werbung fehlt) | +| FR-REC-3 | Einrichtungsakquise: Lead anlegen → Ansprechpartner erfassen → Kontakt → Gesprächsnotiz → Ergebnis → automatische Wiedervorlage nach 14 Tagen [Blueprint 3.5] | Sascha | Wiedervorlage wird ohne manuellen Trigger vom System erzeugt | ⬜ | +| FR-REC-4 | Sascha hat keinen Zugriff auf Rechnungen, Zahlungen, Gewinn, Margen, Controlling [Blueprint 6.2] | System | Rollenprüfung blockiert Zugriff serverseitig | ⬜ | + +### 4.8 Controlling + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-CTL-1 | Auswertungen: Umsatz, Kosten, Gewinn, Deckungsbeitrag, Margen, Auslastung, Fehlzeiten, Einrichtungsrentabilität, offene Forderungen, Recruiting-/Marketing-Erfolg, Fahrzeugkosten [Blueprint 11] | Sabina, Malik (voll), Sabrina (eingeschränkt) | Jede Kennzahl aus Core-Daten berechnet, nicht manuell gepflegt | ⬜ | +| FR-CTL-2 | Drei-Monats-Durchschnitt und Trends/Prognosen [Blueprint 11] | Sabina, Malik | Rollierender Durchschnitt wird automatisch aktualisiert | ⬜ | +| FR-CTL-3 | Vollständige Gewinn-/Margendaten ausschließlich für Sabina/Malik [Blueprint 11] | System | Andere Rollen erhalten aggregierte/gekürzte Werte oder keinen Zugriff | ⬜ | + +### 4.9 OMSORG Connect (Mobile) + +| ID | Anforderung | Akteur | Akzeptanzkriterium | Status | +|---|---|---|---|---| +| FR-CON-1 | Zeiterfassung, digitaler Tätigkeitsnachweis, Fahrtenbuch, Einsatzanweisung, Krankmeldung, Urlaub, Dokumente, News, Benefits, Ansprechpartner [omsorg.md, Blueprint 4.2] | Außendienst | Jede Funktion als eigener Menüpunkt erreichbar | 🔶 (vorhanden: Stundennachweis-Upload, Urlaubs-/Abwesenheitsantrag, Dokumentenarchiv, News, Benefits, Einsatzanweisung, Fortbildung, Dienstplan, Werben, Bewertung — **Fahrtenbuch fehlt vollständig**, Zeiterfassung ist Upload statt Live-Erfassung) | +| FR-CON-2 | Automatische Synchronisation mit OMSORG Desktop [omsorg.md] | System | Änderung in Connect ist ohne manuellen Export in Desktop sichtbar | ⬜ (blockiert durch fehlendes OMSORG Backend, s. Abschnitt 2) | +| FR-CON-3 | PWA-Installierbarkeit ("Add to Home Screen") | Außendienst | Manifest + Service Worker vorhanden und funktionsfähig | ✅ (`manifest.webmanifest`, `service-worker.js`) | +| FR-CON-4 | Login-Rate-Limiting: 5 Fehlversuche → 10 Min. Sperre | Außendienst/alle | Nach 5 Fehlversuchen wird Login für 10 Minuten blockiert | ✅ (`index.php`, `login_attempts`-Tabelle, Migration 004) | + +### 4.10 OMSORG Backend – Event-Schicht (Engine, Ereignis → Aktion) + +Die Event-Schicht ist Teil des Backends (siehe Abschnitt 2) und läuft im selben Prozess/Service wie die Datenschicht (Core, Abschnitt 6) — nicht als separates Deployable. + +| Trigger | Erwartete Aktionen [Blueprint 20.2] | Status | +|---|---|---| +| Krankmeldung eingereicht | Dashboard aktualisieren, Disposition informieren, Einsatz markieren, Ersatzvorschläge anzeigen, Einsatzanweisung vorbereiten, Historie ergänzen | ⬜ | +| Tätigkeitsnachweis fehlt (montags) | Dashboard aktualisieren, Mitarbeiter markieren, Erinnerung via Connect senden, Rechnungsfreigabe blockieren | ⬜ | +| Vertragsende nähert sich (30 Tage) | Dashboard-Warnung, zuständige Person informieren, Kontakt/Vertrag direkt öffnen | ⬜ | +| Neue Bewerbung | Recruiting informieren, Bewerber anlegen, Wiedervorlage erzeugen, Status "Neu" | ⬜ | +| Zahlung eingegangen | Zahlungsstatus ändern, Mahnungen stoppen, Dashboard/Controlling/Liquidität aktualisieren | ⬜ | +| Neuer Mitarbeiter beginnt | Dashboard-Hinweis, Personalakte/Dokumente prüfen, App-Zugang vorbereiten, Einsatzanweisung kontrollieren | ⬜ | +| Rechnung überfällig | Warnung, Mahnentwurf, Freigabe, Versand, Mahnhistorie | ⬜ | + +**FR-ENG-1**: Die Event-Schicht besitzt keine eigene UI, arbeitet ereignisbasiert im Hintergrund und schreibt alle Aktionen protokolliert in die jeweiligen Objekte der Datenschicht [Blueprint 20.1, 20.6]. Status: ⬜ — bislang keinerlei Automatisierungsschicht im Repo vorhanden. + +### 4.11 Outlook-Integration (spätere Phase) + +| ID | Anforderung | Status | +|---|---|---| +| FR-OUT-1 | Neue E-Mails im Morning Briefing, E-Mail↔Einrichtung/Mitarbeiter-Zuordnung, Auftrag/Aufgabe aus E-Mail erzeugen, Rechnungsversand dokumentieren [Blueprint 12] | ⬜ — explizit als spätere Roadmap-Phase (Phase 7) markiert, kein Blocker für Phase 1–6 | + +--- + +## 5. Nicht-funktionale Anforderungen (NFR) + +| ID | Anforderung | Quelle | Status | +|---|---|---|---| +| NFR-1 | DSGVO-konforme Verarbeitung, minimale notwendige Berechtigungen | Blueprint 13 | 🔶 (rollenbasierte Sichten in Connect vorhanden, keine dokumentierte DSGVO-Prüfung) | +| NFR-2 | Getrennte Benutzerkonten, sichere Anmeldung, Sitzungssperre | Blueprint 13 | ✅ (Session-Auth, Rate-Limiting in `lib/auth.php`) | +| NFR-3 | Verschlüsselte Datenübertragung und -speicherung sensibler Daten | Blueprint 13 | 🔶 (HTTPS wird vorausgesetzt, aber nicht im Repo erzwungen; keine Feldverschlüsselung für besonders sensible Daten) | +| NFR-4 | CSRF-Schutz auf jedem POST-Formular | omsorgWeb CLAUDE.md | ✅ (`csrf_field()`/`verify_csrf()` durchgängig verwendet) | +| NFR-5 | Ausgabe-Escaping gegen XSS | omsorgWeb CLAUDE.md | ✅ (`e()`-Helper) | +| NFR-6 | Automatische Backups + regelmäßige Wiederherstellungsprüfung | Blueprint 13 | ⬜ | +| NFR-7 | Nachvollziehbare Änderungshistorie / Protokollierung sicherheitsrelevanter Vorgänge | Blueprint 13, 19.8 | 🔶 (Login-Versuche protokolliert; generisches Audit-Log für Datensatzänderungen fehlt) | +| NFR-8 | Aufbewahrungs- und Löschkonzept, Schutz vor unbefugtem Export | Blueprint 13 | ⬜ | +| NFR-9 | Berechtigungsprüfung auf Oberflächen- **und** Datenebene | Blueprint 13, 19.8 | 🔶 (in Connect vorhanden für Außendienst-Eigendaten; granulare Rechtematrix für Büromitarbeiter fehlt) | +| NFR-10 | White-Label-Fähigkeit: Branding (Name, Logo, Farben, Schrift, Vorlagen) von Geschäftslogik getrennt | Blueprint 14 | ⬜ (aktuell Branding fest im Code/CSS von `omsorgWeb`/`omsorgapp`) | +| NFR-11 | Vollständige Mandantentrennung für spätere Multi-Tenant-Nutzung | Blueprint 14 | ⬜ (nicht Teil der aktuellen Datenmodelle; explizit erst „bei Bedarf") | +| NFR-12 | Wiederverwendbare Komponenten, modulare Architektur, klare Verantwortlichkeiten | Blueprint 15 | 🔶 (`omsorgapp/src/components/ui` als Ansatz vorhanden) | + +--- + +## 6. Datenmodell-Anforderungen (OMSORG Backend – Datenschicht/Core) + +Die sechs Core-Objekte [Blueprint Kap. 19]: **Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung**. + +Verbindliche Regeln [Blueprint 19.8]: + +1. Jeder Datensatz besitzt eine eindeutige interne ID. +2. Beziehungen werden über IDs hergestellt, nicht durch doppelte Texteingaben. +3. Stammdaten werden nur an einer zentralen Stelle gepflegt. +4. Änderungen werden nachvollziehbar protokolliert. +5. Löschungen sensibler/geschäftsrelevanter Daten erfolgen nicht unkontrolliert (Soft-Delete/Archivierung statt Hard-Delete). +6. Berechtigungen werden auf Daten- und Funktionsebene geprüft. +7. Automatisierungen (Event-Schicht) greifen ausschließlich auf verlässliche, freigegebene Daten zu — dadurch, dass Datenschicht und Event-Schicht im selben Backend-Prozess laufen, ist dies technisch einfacher garantierbar als bei getrennten Services (kein Risiko von veralteten/inkonsistenten Zwischenständen durch asynchrone Synchronisation). +8. Rechnungen entstehen ausschließlich aus freigegebenen Arbeitszeiten. +9. OMSORG Connect erhält nur die für den jeweiligen Mitarbeiter freigegebenen Daten. + +**FR-CORE-1**: Es gibt genau eine Datenquelle für jedes Core-Objekt, die von Desktop, Connect, Event-Schicht (Engine) und Controlling gemeinsam genutzt wird — bereitgestellt durch die Datenschicht des Backends. Status: 🔶 — das Backend selbst existiert jetzt (`omsorgCore`, PostgreSQL/EF Core, alle 6 Core-Objekte als Entitäten angelegt). `omsorgapp` ist für das Mitarbeiter-Objekt bereits umgestellt (echtes CRUD über `omsorgCore`, keine JSON-Persistenz mehr für dieses Modul), die übrigen `omsorgapp`-Fachmodule und `omsorgWeb` (MySQL) sprechen noch nicht damit. Solange diese Anbindung für die restlichen Objekte/Projekte fehlt, bleiben es weiterhin getrennte Speicher parallel zum neuen Backend. **Anbindung der bestehenden Projekte an `omsorgCore` ist die zentrale technische Voraussetzung, bevor Phase 2 ff. sinnvoll umsetzbar sind.** + +--- + +## 7. Rechtematrix (Auszug, aus Blueprint Kap. 6 abgeleitet) + +| Modul | Sabina/Malik | Sabrina | Sascha | Außendienst | +|---|---|---|---|---| +| Mitarbeiter (Personalakte) | voll | voll | eingeschränkt (soweit erforderlich) | nur eigene Daten | +| Einrichtungen/CRM | voll | voll | Leads/Akquise | – | +| Aufträge/Disposition | voll | voll | – | nur eigene Einsätze | +| Zeiterfassung | voll | prüfen/freigeben | – | eigene erfassen | +| Rechnungen/Zahlungen | voll | erstellen/freigeben | **kein Zugriff** | – | +| Recruiting/CRM | voll | nutzen | voll | – | +| Controlling | voll (inkl. Gewinn/Margen) | eingeschränkt (kein Gewinn) | **kein Zugriff** | – | +| Benutzerverwaltung | voll | – | – | – | + +Für zukünftige Büromitarbeiter: Rolle als Vorlage, zusätzlich granular je Modul einstellbar: sehen / lesen / anlegen / bearbeiten / löschen / exportieren / freigeben [Blueprint 6.5]. + +--- + +## 8. Abnahmekriterien (Definition of Done, je Gruppe) + +- **Dashboard**: Alle in 4.1 gelisteten Kategorien sind sichtbar, rollenabhängig gefiltert, klickbar zur Zielaktion. +- **Mitarbeiter/Einrichtungen**: CRUD vollständig, keine Pflichtfeld-Lücken, Änderungen protokolliert. +- **Einsatzmanagement**: Zuweisung ohne Qualifikations-/Zeitkonflikt möglich; bei Konflikt blockiert oder mit Warnung. +- **Zeiterfassung/Rechnung**: Rechnung technisch nicht erzeugbar aus nicht freigegebener Zeit (FR-ZE-3) — verifizierbar per Testfall, nicht nur Review. +- **Rollen/Rechte**: Jeder Endpunkt/jede Route prüft Berechtigung serverseitig, nicht nur im UI. +- **Engine**: Jeder in 4.10 gelistete Trigger löst nachweisbar (Log-Eintrag) die zugehörigen Aktionen aus. + +--- + +## 9. Offene Punkte / Annahmen + +- Tiefe der Outlook-Integration (nur Anzeige vs. bidirektionale Synchronisation) ist noch nicht spezifiziert. +- Zeitpunkt und Umfang der Mandantentrennung (White-Label) ist bewusst auf „bei Bedarf" verschoben — keine Anforderung für aktuelle Phasen. +- Unklar, ob das OMSORG Backend (Core + Engine) als eigenständiger Service (eigene API, von Desktop und Connect angesprochen) oder als gemeinsame DB-/Logikschicht unter `omsorgWeb`/`omsorgapp` realisiert wird — technische Entscheidung noch offen, aber Voraussetzung für FR-CORE-1. Klar ist nur: Core (Datenschicht) und Engine (Event-Schicht) gehören in denselben Service, nicht in zwei getrennte. +- Fahrtenbuch (FR-CON-1) ist in keiner der bestehenden Codebasen auch nur ansatzweise vorhanden — vollständige Neuentwicklung nötig. +- Rechtsform granularer Einzelrechte (FR-MA-5) — Datenmodell für Berechtigungen (Modul × Recht) ist noch nicht definiert. + +--- + +## 10. Rückverweis zur Roadmap [Blueprint Kap. 16] + +| Roadmap-Phase | Betroffene FR-Gruppen | Voraussetzung erfüllt? | +|---|---|---| +| Phase 1 – Fundament | FR-CORE-1, NFR-6, NFR-7, NFR-9, NFR-10 | 🔶 Teilweise — Backend-Grundgerüst mit Rechtesystem/Auth/Datenmodell steht, Mitarbeiter-Objekt in `omsorgapp` bereits angebunden, aber Anbindung von `omsorgWeb` und der übrigen `omsorgapp`-Fachmodule fehlt noch, ebenso Backups (NFR-6) und vollständiger Audit-Trail (NFR-7) | +| Phase 2 – Stammdaten | FR-MA-1..5, FR-EIN-1..5 | Blockiert durch Phase 1 | +| Phase 3 – Operatives Geschäft | FR-EM-1..5 | Blockiert durch Phase 1/2 | +| Phase 4 – Zeit und Abrechnung | FR-ZE-1..4, FR-RE-1..4 | Teilbasis vorhanden (Connect-Upload), aber ohne Core nicht rechnungsfähig | +| Phase 5 – Recruiting und CRM | FR-REC-1..4 | Teilbasis vorhanden (Werben), CRM-Pipeline fehlt | +| Phase 6 – OMSORG Connect | FR-CON-1..4 | 🔶 größtenteils vorhanden, Fahrtenbuch + Sync offen | +| Phase 7 – Dashboard und Controlling | FR-DASH-1..4, FR-CTL-1..3, FR-OUT-1 | Teilbasis (`omsorgapp` Dashboard-Ansätze) vorhanden | + +**Empfehlung für den nächsten Sprint:** Phase 1 zuerst — das OMSORG Backend (Core + Engine als eine Komponente) mit gemeinsamer Datenbasis definieren (Datenmodell für die 6 Core-Objekte, IDs, Beziehungen, plus die Event-Schicht als Aufsatz auf denselben Daten), bevor weitere Module in `omsorgapp` gebaut werden. Ohne diesen Schritt wächst die Diskrepanz zwischen Connect (MySQL, produktiv) und Desktop (JSON, lokal) weiter. diff --git a/mitarbeiter-app/actions/save-profile.php b/mitarbeiter-app/actions/save-profile.php deleted file mode 100644 index 4df746e..0000000 --- a/mitarbeiter-app/actions/save-profile.php +++ /dev/null @@ -1,53 +0,0 @@ -prepare('UPDATE users SET email = ?, telefon = ? WHERE username = ?'); - $stmt->execute([$email, $telefon, $username]); - - header('Location: ../pages/settings.php?saved=1'); - exit; -} - -if ($action === 'password') { - $old = $_POST['old_password'] ?? ''; - $new = $_POST['new_password'] ?? ''; - $repeat = $_POST['new_password_repeat'] ?? ''; - - $user = current_user(); - - if (!$user || !password_verify($old, $user['password_hash'])) { - header('Location: ../pages/settings.php?error=' . urlencode('Aktuelles Passwort ist falsch.')); - exit; - } - if (mb_strlen($new) < 8) { - header('Location: ../pages/settings.php?error=' . urlencode('Neues Passwort muss mindestens 8 Zeichen haben.')); - exit; - } - if ($new !== $repeat) { - header('Location: ../pages/settings.php?error=' . urlencode('Die neuen Passwörter stimmen nicht überein.')); - exit; - } - - $hash = password_hash($new, PASSWORD_BCRYPT, ['cost' => 12]); - $stmt = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?'); - $stmt->execute([$hash, $username]); - - header('Location: ../pages/settings.php?pwchanged=1'); - exit; -} - -header('Location: ../pages/settings.php'); -exit; diff --git a/mitarbeiter-app/actions/submit-urlaubsantrag.php b/mitarbeiter-app/actions/submit-urlaubsantrag.php deleted file mode 100644 index 66120ae..0000000 --- a/mitarbeiter-app/actions/submit-urlaubsantrag.php +++ /dev/null @@ -1,56 +0,0 @@ - strtotime($bis)) { - header('Location: ' . $back . '?error=' . urlencode('Das Startdatum muss vor dem Enddatum liegen.')); - exit; -} - -$user_id = current_user()['id']; -$now = date('c'); - -db()->prepare( - 'INSERT INTO requests_urlaubsantrag (user_id, status, admin_note, created_at, updated_at, von, bis, vertretung, nachricht) - VALUES (?, \'pending\', \'\', ?, ?, ?, ?, ?, ?)' -)->execute([$user_id, $now, $now, $von, $bis, $vertretung, $nachricht]); - -$config = require __DIR__ . '/../lib/config.php'; -smtp_send( - $config, - $config['mail_sabrina'], - 'Neuer Urlaubsantrag: ' . current_name(), - "Ein Urlaubsantrag wurde eingereicht.\n\n" - . 'Mitarbeiter: ' . current_name() . "\n" - . 'Von: ' . $von . "\n" - . 'Bis: ' . $bis . "\n" - . 'Vertretung: ' . ($vertretung ?: '—') . "\n" - . 'Nachricht: ' . ($nachricht ?: '—') . "\n\n" - . "---\nGesendet über OMSORG Connect" -); - -header('Location: ' . $back . '?success=1'); -exit; diff --git a/mitarbeiter-app/lib/auth.php b/mitarbeiter-app/lib/auth.php deleted file mode 100644 index c5bae35..0000000 --- a/mitarbeiter-app/lib/auth.php +++ /dev/null @@ -1,111 +0,0 @@ - 0, - 'path' => '/', - 'httponly' => true, - 'secure' => $secure, - 'samesite' => 'Lax', -]); -session_name('OMSORG_FELIX_APP'); -session_start(); - -function is_logged_in(): bool { - if (empty($_SESSION['felix_logged_in']) || empty($_SESSION['felix_username'])) { - return false; - } - $stmt = db()->prepare('SELECT active FROM users WHERE username = ?'); - $stmt->execute([$_SESSION['felix_username']]); - $row = $stmt->fetch(); - return $row && (int)$row['active'] === 1; -} - -function require_login(): void { - if (!is_logged_in()) { - header('Location: ../index.php'); - exit; - } - if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)) { - verify_csrf(); - } -} - -function csrf_token(): string { - if (empty($_SESSION['csrf_token'])) { - $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); - } - return $_SESSION['csrf_token']; -} - -function csrf_field(): string { - return ''; -} - -function verify_csrf(): void { - $token = $_POST['csrf_token'] ?? ''; - if (!hash_equals(csrf_token(), $token)) { - http_response_code(403); - exit('Ungültige Anfrage.'); - } -} - -function require_admin(): void { - require_login(); - if (!is_admin()) { - header('Location: ../pages/dashboard.php'); - exit; - } -} - -function is_admin(): bool { - return isset($_SESSION['felix_role']) && $_SESSION['felix_role'] === 'admin'; -} - -function current_user(): array|false { - static $cache = null; - static $cachedFor = null; - $username = $_SESSION['felix_username'] ?? ''; - if ($username === '') return false; - if ($cache !== null && $cachedFor === $username) return $cache; - $stmt = db()->prepare('SELECT * FROM users WHERE username = ?'); - $stmt->execute([$username]); - $cache = $stmt->fetch(); - $cachedFor = $username; - return $cache; -} - -function current_username(): string { - return $_SESSION['felix_username'] ?? ''; -} - -function current_name(): string { - return $_SESSION['felix_name'] ?? ''; -} - -function e(string $s): string { - return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); -} - -// --- Login rate limiting (IP-based, persisted in DB) --- - -function login_recent_fails(string $ip, int $windowSec = 600): int { - $cutoff = date('c', time() - $windowSec); - $stmt = db()->prepare( - 'SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND attempted_at >= ?' - ); - $stmt->execute([$ip, $cutoff]); - return (int) $stmt->fetchColumn(); -} - -function login_record_fail(string $ip, string $username): void { - db()->prepare( - 'INSERT INTO login_attempts (ip, username, attempted_at) VALUES (?, ?, ?)' - )->execute([$ip, $username, date('c')]); -} - -function login_clear_fails(string $ip): void { - db()->prepare('DELETE FROM login_attempts WHERE ip = ?')->execute([$ip]); -} diff --git a/omsorg.md b/omsorg.md new file mode 100644 index 0000000..ac38307 --- /dev/null +++ b/omsorg.md @@ -0,0 +1,238 @@ +🏛 OMSORG Business Controls Pro +Das digitale Herzstück von Omsorg +Ziel: +Eine einzige Software, mit der Omsorg den kompletten Arbeitsalltag abwickelt – ohne zusätzliche Programme für Zeiterfassung, CRM, Fahrtenbuch oder Rechnungen. +📌 Die Grundarchitektur +1. OMSORG Desktop +Für: +Sabina (Admin) +Malik (Admin) +Sabrina (Disposition/Buchhaltung) +Sascha (Recruiting) +zukünftige Büromitarbeiter +Hier laufen alle Unternehmensprozesse zusammen. +2. OMSORG Connect (Handy-App) +Für Außendienstmitarbeiter: +Zeiterfassung +digitaler Tätigkeitsnachweis +Fahrtenbuch +Einsatzanweisung +Krankmeldung +Urlaub +Dokumente +News +Benefits +Ansprechpartner +Alle Daten werden automatisch mit OMSORG Desktop synchronisiert. +3. OMSORG Core +Die gemeinsame Datenbasis. +Jede Information wird nur einmal gespeichert. +Zentrale Objekte: +👤 Mitarbeiter +🏥 Einrichtung +📄 Vertrag +📋 Auftrag +⏱️ Zeiterfassung +💰 Rechnung +Alle Module greifen auf dieselben Daten zu. +4. OMSORG Engine +Das intelligente Herz der Software. +Sie reagiert automatisch auf Ereignisse. +Beispiele: +Vertrag läuft aus +Rechnung überfällig +Tätigkeitsnachweis fehlt +Krankmeldung +neuer Mitarbeiter beginnt +Wiedervorlage für Kaltaquise +🏠 Dashboard (Morning Briefing) +Der Arbeitstag beginnt immer im Dashboard. +Anzeige: +📬 neue Outlook-Mails +🏥 Krankmeldungen +📄 fehlende Tätigkeitsnachweise +📅 Vertragsverlängerungen +💰 offene Rechnungen +☎️ Kaltaquise heute +👤 neue Mitarbeiter +📈 Auslastung +💵 Gewinn (nur Sabina & Malik) +Jede Information führt direkt zur passenden Aktion. +👥 Mitarbeiter +Digitale Personalakte. +Bereiche: +Übersicht +Stammdaten +Arbeitsvertrag +Dokumente +Qualifikationen +Fortbildungen +Einsätze +Zeiterfassung +Fahrtenbuch +Urlaub +Benefits +Historie +🏥 Einrichtungen (CRM) +Jede Einrichtung besitzt: +Stammdaten +Rechnungsadresse +Ansprechpartner +Telefonnummern +E-Mail +Konditionen +Verträge +Aufträge +Rechnungen +CRM-Historie +Status: +Lead +↓ +kontaktiert +↓ +kein Bedarf +↓ +Wiedervorlage +↓ +Interesse +↓ +Angebot +↓ +Kunde +↓ +Bestandskunde +📋 Einsatzmanagement +Der Einsatz verbindet alle Bereiche. +Einrichtung +↓ +Auftrag +↓ +Mitarbeiter +↓ +Einsatz +↓ +Zeiterfassung +↓ +Tätigkeitsnachweis +↓ +Rechnung +↓ +Controlling +Jeder Einsatz besitzt eine vollständige Historie. +⏱ Zeiterfassung +Nach Vorbild von Clockodo. +Aber vollständig integriert. +Erfassung: +Beginn +Pause +Ende +Einrichtung +Auftrag +Nachtstunden +Wochenendstunden +Feiertagsstunden +↓ +automatische Rechnung +↓ +Controlling +💰 Rechnungen +Automatisch aus: +freigegebenen Stunden +Konditionen der Einrichtung +Zuschlägen +↓ +PDF +↓ +Versand +↓ +14 Tage Zahlungsziel +↓ +Mahnung +↓ +Zahlung +☎ Recruiting +Zwei Bereiche. +Mitarbeiter +Bewerber +Gespräche +Werbung +Indeed +Facebook +Instagram +Einrichtungen +Kaltaquise +CRM +Wiedervorlage +Telefon +Angebote +Neukunden +📊 Controlling +Automatische Auswertungen. +Umsatz +Gewinn +Deckungsbeitrag +Auslastung +Drei-Monats-Durchschnitt +offene Forderungen +Recruiting-Erfolg +🔐 Datenschutz +Sehr wichtig. +DSGVO +Rechteverwaltung +verschlüsselte Daten +automatische Backups +Protokollierung +Mitarbeiter sehen nur eigene Daten +👤 Rollen +Sabina & Malik +Vollzugriff. +Sabrina +Disposition +Rechnungen +Zahlungen +Recruiting +Kein vollständiger Gewinn. +Sascha +Recruiting +CRM +Einrichtungen +Mitarbeiter +Kein Controlling. +Außendienst +Nur OMSORG Connect. +📖 Entwicklungsprinzip +Wir entwickeln immer nach derselben Reihenfolge: +Blueprint + +↓ + +Architektur + +↓ + +Software + +↓ + +Test + +↓ + +Git + +↓ + +Nächster Sprint +❤️ Unser Leitsatz +Wir entwickeln keine Software, um Daten zu verwalten. +Wir entwickeln ein System, das Menschen unterstützt, Zeit spart und Omsorg jeden Tag ein Stück besser macht. +🚀 Unsere Vision +In einigen Jahren soll Omsorg morgens nur noch eine Software öffnen: +OMSORG Business Controls Pro +Keine zusätzliche CRM-Software. +Keine separate Zeiterfassung. +Keine extra Fahrtenbuch-App. +Keine Insellösungen. +Ein Login. Ein System. Ein Datenbestand. +Und genau das macht OMSORG Business Controls Pro zum digitalen Herzstück von Omsorg. ❤️ +Ich glaube, diese Zusammenfassung beschreibt unsere Vision inzwischen sehr gut und wird uns bei jedem weiteren Sprint als Orientierung dienen. diff --git a/omsorgCore/.config/dotnet-tools.json b/omsorgCore/.config/dotnet-tools.json new file mode 100644 index 0000000..40d4373 --- /dev/null +++ b/omsorgCore/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.10", + "commands": [ + "dotnet-ef" + ] + } + } +} \ No newline at end of file diff --git a/omsorgCore/.gitignore b/omsorgCore/.gitignore new file mode 100644 index 0000000..970c8f0 --- /dev/null +++ b/omsorgCore/.gitignore @@ -0,0 +1,12 @@ +bin/ +obj/ +*.user +.vs/ +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Enthält echte Secrets (DB-Passwort, JWT-Secret) - nie einchecken +src/OmsorgCore.Api/appsettings.Development.json diff --git a/omsorgCore/CLAUDE.md b/omsorgCore/CLAUDE.md new file mode 100644 index 0000000..7a67f00 --- /dev/null +++ b/omsorgCore/CLAUDE.md @@ -0,0 +1,179 @@ +# CLAUDE.md — omsorgCore (OMSORG Backend: Core + Engine) + +Gilt zusätzlich zur Root-`CLAUDE.md`. Das Grundgerüst ist angelegt und baut (`dotnet build` läuft grün); der fachliche Umfang ist bewusst noch minimal (Fundament, nicht Vollständigkeit). + +## Auftrag dieses Projekts + +`omsorgCore` ist die gemeinsame Datenbasis + Automatisierungsschicht für die gesamte Plattform (siehe `REQUIREMENTS.md` Abschnitt 2 und 6, Blueprint Kap. 19/20). Es soll schrittweise ersetzen: +- die MySQL-Datenhaltung in `omsorgWeb/mitarbeiter-app` +- die lokale JSON-Datenhaltung in `omsorgapp` + +Beide bestehenden Projekte sollen künftig gegen dieses Backend sprechen statt eigene, getrennte Datenspeicher zu pflegen. Diese Anbindung ist noch **nicht** erfolgt — aktueller Schritt ist nur das Backend-Fundament selbst. + +## Tech-Stack (umgesetzt) + +- C# / **.NET 8** (LTS) +- ASP.NET Core Web API mit **Controllern** (kein Minimal-API-Stil) +- **PostgreSQL** über `Npgsql.EntityFrameworkCore.PostgreSQL` 8.0.10 (bewusst auf net8-kompatible Version gepinnt — neuere Paketversionen zielen auf .NET 10) +- **JWT Bearer Tokens** für Auth (`Microsoft.AspNetCore.Authentication.JwtBearer`) +- Passwort-Hashing über `Microsoft.AspNetCore.Identity.PasswordHasher` (PBKDF2, kein eigenes Krypto-Rad) +- **Core + Engine = eine Backend-Komponente**, kein separates Deployable für die Engine — die Event-Schicht läuft im selben Prozess wie die Datenschicht (siehe Root-`CLAUDE.md` und `REQUIREMENTS.md` Abschnitt 1.2) + +## Reale Projektstruktur + +``` +omsorgCore/ + OmsorgCore.sln + .config/dotnet-tools.json # lokales dotnet-ef Tool (dotnet tool restore) + src/ + OmsorgCore.Domain/ # Entitäten, Enums. Keine Abhängigkeit auf andere Projekte. + Common/ # Entity, AuditableEntity (Basisklassen) + Enums/ # ModuleType, PermissionAction, PermissionEffect + Entities/ # Employee, Facility, Contract, Order, TimeEntry, Invoice, + # User, Role, RolePermission, UserPermissionOverride + OmsorgCore.Application/ # Business-Logik. Abhängig von Domain. + Abstractions/ # Interfaces: IEmployeeRepository, IUserRepository, + # IPasswordHasher, IJwtTokenGenerator, ICurrentUserService, + # IPermissionService + Services/ # PermissionService, AuthService, EmployeeService, SessionAdminService + DependencyInjection.cs # AddApplication() + OmsorgCore.Infrastructure/ # Technische Umsetzung. Abhängig von Domain + Application. + Persistence/ + OmsorgCoreDbContext.cs + Configurations/ # ein IEntityTypeConfiguration pro Entität + Migrations/ # EF-Core-Migrationen (InitialCreate bereits erzeugt) + Repositories/ # EmployeeRepository, UserRepository, RefreshTokenRepository (implementieren Application-Interfaces) + Security/ # PasswordHasher, JwtOptions, JwtTokenGenerator, RefreshTokenOptions, RefreshTokenGenerator + DependencyInjection.cs # AddInfrastructure(configuration) + OmsorgCore.Engine/ # Event-Schicht. Abhängig von Domain + Application. + Events/ # IDomainEvent, IDomainEventHandler, IDomainEventDispatcher, + # DomainEventDispatcher (In-Process, kein Message-Bus), Beispiel-Event + Handlers/ # Beispiel-Handler (EmployeeCreatedHandler) + DependencyInjection.cs # AddEngine() + OmsorgCore.Api/ # ASP.NET Core Web API. Abhängig von Application+Infrastructure+Engine. + Controllers/ # AuthController, EmployeesController, HealthController, AdminSessionsController + Contracts/ # Request-/Response-DTOs (LoginRequest, EmployeeResponse, ...) + Security/ # CurrentUserService, RequirePermissionAttribute + Program.cs # einziger Ort, an dem alle Schichten verdrahtet werden + tests/ + OmsorgCore.Tests/ # xUnit, referenziert Domain + Application +``` + +**Konvention: eine Klasse/ein Interface/ein Enum pro Datei.** Ausnahme: keine — auch kleine DTOs (Requests/Responses als `record`) bekommen eine eigene Datei. + +**Abhängigkeitsrichtung ist strikt:** Domain kennt niemanden. Application kennt nur Domain und definiert Interfaces, die Infrastructure implementiert (Ports-and-Adapters). Engine kennt Domain + Application, nicht Infrastructure oder Api. Api verdrahtet alles ausschließlich in `Program.cs` — Controller rufen nur Application-Services und den `IDomainEventDispatcher` (Engine) auf, **niemals** direkt `OmsorgCoreDbContext`/EF Core. + +## Rechtesystem + +Rolle liefert Standard-Rechte (`RolePermission`: Modul × Aktion), ein individueller `UserPermissionOverride` (Grant/Revoke) gewinnt immer gegen den Rollen-Default — siehe `PermissionService.HasPermissionAsync` (`src/OmsorgCore.Application/Services/PermissionService.cs`). Deckt Blueprint 6.5 ("Rolle als Vorlage + individuelle Rechte") ab. + +Die aufgelösten Rechte eines Users (nicht nur eine einzelne Prüfung) liefert `PermissionService.GetGrantedPermissionsAsync` als Liste von `PermissionGrant(Module, Action)`. Exponiert über `GET /api/auth/me` (`AuthController.Me`, `[Authorize]`) als `MeResponse { username, role, permissions: [{ module, action }, ...] }` — der einzige Weg, wie granulare Rechte den Client erreichen (das JWT trägt nur den Rollennamen). `omsorgapp` ruft diesen Endpunkt nach Login/Refresh auf (siehe `omsorgapp/CLAUDE.md`, "Rechtesystem im Client") und trifft UI-Entscheidungen darüber statt über einen Rollennamen-Vergleich. + +Rechteprüfung auf Controller-Actions: +```csharp +[RequirePermission(ModuleType.Employees, PermissionAction.Create)] +``` +Das Attribut (`src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs`) prüft serverseitig über `IPermissionService` — nicht nur im Client (REQUIREMENTS.md NFR-9). Jeder Controller außer `AuthController` trägt zusätzlich `[Authorize]`. + +## Auth-Flow + +1. `POST /api/auth/login` (`AuthController`) → `AuthService.LoginAsync` prüft Username/Passwort-Hash, widerruft **alle bisherigen aktiven Refresh-Tokens dieses Users und würfelt seinen `SecurityStamp` neu** (`EndOtherSessionsAsync` — ein User hat immer nur eine aktive Session; ältere Sessions werden per Killswitch sofort ungültig, siehe "Session-Killswitch" unten), dann erzeugt `JwtTokenGenerator` ein Access-Token (Claims `sub`/`name`/`role`) + `RefreshTokenGenerator` einen langlebigen Refresh-Token. Response (`LoginResponse`): `accessToken`, `refreshToken`, `expiresAt` (camelCase, Default-JSON-Serialisierung von ASP.NET Core). +2. Client sendet Access-Token als `Authorization: Bearer `. +3. `Program.cs` validiert das Token gegen `Jwt:Issuer`/`Jwt:Audience`/`Jwt:Secret` aus der Konfiguration. +4. `POST /api/auth/refresh` (kein `[Authorize]` — der Refresh-Token selbst ist das Credential): `AuthService.RefreshAsync` prüft den Refresh-Token per Hash-Lookup, **rotiert** ihn (alten Token widerrufen, neuen ausstellen, per `ReplacedByTokenId` verkettet) und liefert ein neues Token-Paar. Erlaubt langlebige Sessions ohne täglichen Passwort-Login (siehe `omsorgapp/CLAUDE.md`). +5. `POST /api/auth/logout` widerruft den vorgelegten Refresh-Token (`AuthService.RevokeAsync`, idempotent). + +**Refresh-Token:** kein Rohtoken wird gespeichert, nur sein SHA-256-Hash (`RefreshToken`-Entity, `IRefreshTokenGenerator`). Gültigkeit über `RefreshToken:ExpiryDays` in `appsettings.json` (Default 60 Tage, sliding — jede Nutzung verlängert effektiv die Session), überschreibbar per `RefreshToken__ExpiryDays`. + +**Secret-Handling:** `appsettings.json` enthält nur Issuer/Audience/ExpiryMinutes/RefreshToken:ExpiryDays. `appsettings.Development.json` enthält einen **lokalen Platzhalter** für `Jwt:Secret` und den Connection-String (`CHANGE_ME_...`) — für echte Umgebungen über Umgebungsvariable (`Jwt__Secret`) oder `dotnet user-secrets` überschreiben, nie ein echtes Secret einchecken. + +**Standard-Admin-Seed:** `DbSeeder.SeedDefaultAdminAsync` (`src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`) legt beim Start **nur im Development-Modus** (`Program.cs`, `app.Environment.IsDevelopment()`) einen Benutzer `admin`/`abersicher` mit einer neuen Rolle `Administrator` (alle `ModuleType`×`PermissionAction`-Kombinationen als `RolePermission`) an — idempotent, läuft nur wenn noch **kein** `User` existiert. DB-Fehler dabei (z. B. keine Verbindung — Migrationen sind zu diesem Zeitpunkt bereits automatisch angewendet, siehe "Datenbank" unten) sind nicht fatal, werden nur geloggt (`try/catch` um den Seed-Aufruf). Bewusst **nicht** in Produktion aktiv, um kein bekanntes Standard-Passwort auszuliefern — für einen echten Produktivbetrieb muss ein richtiger User-Anlage-Flow her. + +## Session-Killswitch (SecurityStamp) + +Ein Access-Token ist als JWT zustandslos gültig bis zum Ablauf (60 Min) — ein reiner Refresh-Token-Widerruf verhindert nur das *stille Verlängern*, der bereits ausgestellte Access-Token bliebe sonst bis zu 60 Minuten gültig. Für einen echten Not-Aus-Schalter (Debug-Sicht in `omsorgapp`, "alle Nutzer sofort zum Neu-Login zwingen") trägt `User.SecurityStamp` (Guid) einen Wert, der: +1. bei jedem Access-Token-Ausstellen als Claim `"sst"` mit eingebettet wird (`JwtTokenGenerator`), +2. bei **jedem** authentifizierten Request per `JwtBearerEvents.OnTokenValidated` (`Program.cs`) gegen den aktuellen `User.SecurityStamp` in der DB geprüft wird (`IUserRepository.GetByIdAsync`, schlanker Lookup ohne Includes) — bei Mismatch oder fehlendem Claim `context.Fail(...)`, sofort 401, unabhängig von der Token-Restlaufzeit. + +`ISessionAdminService`/`SessionAdminService` (`src/OmsorgCore.Application/Services/`) kapselt die Admin-Operationen: +- `GetActiveSessionsAsync()` — alle nicht widerrufenen/nicht abgelaufenen Refresh-Tokens (`IRefreshTokenRepository.GetAllActiveAsync`) als `SessionInfo` (Id, Username, CreatedAt, ExpiresAt). +- `RevokeSessionAsync(sessionId)` — widerruft genau diesen Refresh-Token und würfelt den `SecurityStamp` nur des zugehörigen Users neu (sofortiger Kick für genau diesen Nutzer). +- `RevokeAllSessionsAsync()` — widerruft alle aktiven Refresh-Tokens und würfelt `SecurityStamp` **aller** Nutzer neu (globaler Killswitch). + +Exponiert über `AdminSessionsController` (`GET /api/admin/sessions`, `POST /api/admin/sessions/{id}/revoke`, `POST /api/admin/sessions/revoke-all`), geschützt über `[RequirePermission(ModuleType.UserManagement, ...)]` — per Default nur die `Administrator`-Rolle aus dem `DbSeeder`. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, im Sidebar-Menü nur sichtbar, wenn `hasPermission("UserManagement", "View")` (siehe `GET /api/auth/me` unten) — dieselbe Rechteprüfung wie serverseitig, kein reiner Rollennamen-Vergleich mehr im Client. + +**Kosten:** ein zusätzlicher DB-Read pro authentifiziertem Request (`GetByIdAsync`). Für die aktuelle Nutzerzahl vernachlässigbar — bei relevantem Traffic-Wachstum wäre ein Cache (z. B. In-Memory mit kurzer TTL) der nächste Schritt, aber kein Caching ohne echten Bedarf, um die Sofortigkeit des Killswitches nicht zu unterlaufen. + +## Passwort-Reset / E-Mail-Versand + +Vollständig implementiert: `PasswordResetCode`-Entity + `PasswordResetService` (PIN anfordern/verifizieren, Passwort setzen, siehe Auth-Flow-Analogie: Session-Revoke + `SecurityStamp`-Rotation nach erfolgreichem Reset), exponiert über `AuthController` (`POST /api/auth/forgot-password/{request,verify,reset}`). + +**E-Mail-Versand:** `IEmailSender` (`OmsorgCore.Application.Abstractions`) hat zwei Implementierungen in `OmsorgCore.Email`, Auswahl über `Email:Provider` zur Startzeit (`DependencyInjection.AddEmail`): +- `"Console"` (Default) — schreibt nur ins Log, kein echter Versand. Sicherer Default für Umgebungen ohne SMTP-Konfiguration. +- `"Smtp"` — echter Versand über MailKit (`SmtpEmailSender`). **Ein gemeinsamer SMTP-Server (`Email:Smtp:*`), darüber mehrere benannte Accounts (`Email:Accounts::*`)** — z. B. ein Account, der exklusiv für Passwort-Reset-Mails verwendet wird, getrennt von einem künftigen allgemeinen Absender. Welcher Account für den Reset-Flow gilt, steht in `Email:PasswordResetAccount` (Default `"PasswordReset"`); `EmailMessage.FromAccountKey` transportiert die Auswahl vom Aufrufer bis zum `SmtpEmailSender`. Details der Keys: `CONFIGURATION.md`. **Zugangsdaten (Host/Username/Passwort) kommen ausschließlich aus appsettings/Env-Var/user-secrets, nie über eine API — es gibt bewusst keinen Settings-Controller oder UI-Formular dafür.** + +**Templating:** Betreff/Text der Passwort-Reset-Mail sind über `Email:PasswordResetTemplate:{Subject,BodyTemplate}` konfigurierbar, Platzhalter im Format `$NAME$` (`$RESET_PIN$`, `$RESET_PIN_EXPIRY_MINUTES$`), ersetzt von `EmailTemplateRenderer.Render` (pure Funktion, unit-getestet in `EmailTemplateRendererTests`). Die Zusammensetzung passiert in `PasswordResetEmailComposer` (`OmsorgCore.Email`, implementiert `IPasswordResetEmailComposer` aus `Application.Abstractions`) — bewusst nicht direkt im `Engine`-Handler, weil `Engine` nur `Domain`+`Application` kennen darf, nicht `Email` (Abhängigkeitsrichtung, siehe oben). `PasswordResetRequestedHandler` (`Engine/Handlers/`) ruft nur `IPasswordResetEmailComposer.Compose(...)` + `IEmailSender.SendAsync(...)` auf. + +**Testversand ohne Zugangsdaten-Leak:** `AdminEmailController` (`POST /api/admin/email/test-send`, `[RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)]`) verschickt eine feste Testmail über den aktuell konfigurierten `IEmailSender` — Host/Username/Passwort verlassen dabei nie den Server, nur Erfolg/Fehlschlag geht an den Client. Im Frontend: `omsorgapp/src/modules/debug/DebugSessionsPage.jsx`, gleicher Rechteschutz wie die Sessions-Verwaltung dort. + +## Datenbank + +- Connection-String-Key: `ConnectionStrings:OmsorgCore` (Format `Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`). +- `dotnet-ef` ist als lokales Tool eingerichtet (`.config/dotnet-tools.json`) — vor erster Nutzung `dotnet tool restore`. Wird nur noch zum **Erzeugen** neuer Migrationen gebraucht (`dotnet ef migrations add ...`), nicht mehr zum Anwenden. +- **`Program.cs` ruft bei jedem Start `db.Database.MigrateAsync()` auf, in jeder Umgebung** (nicht nur Development) — ausstehende Migrationen werden automatisch angewendet, bevor der Server Requests annimmt. Ein manuelles `dotnet ef database update` ist dadurch nur noch zum gezielten Vorab-Prüfen/Debuggen einer Migration nötig, nicht mehr für den normalen Start/Deploy. Schlägt die Migration fehl, crasht der Start bewusst fatal (fail-fast) statt mit einem veralteten Schema weiterzulaufen. +- Migrationen `InitialCreate`, `AddRefreshTokens`, `AddUserSecurityStamp`, `AddEmployeeContactFieldConstraints` und `AddAuditableSoftDelete` existieren (`src/OmsorgCore.Infrastructure/Persistence/Migrations/`) und wurden erfolgreich gegen eine echte PostgreSQL-Instanz angewendet. + +## Build- und Run-Befehle + +```bash +cd omsorgCore +dotnet build # ganze Solution +dotnet tool restore # einmalig, für dotnet-ef + +# Neue Migration erzeugen, nachdem sich das Entity-Modell geändert hat (wird NICHT automatisch angewendet): +dotnet tool run dotnet-ef migrations add \ + --project src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj \ + --startup-project src/OmsorgCore.Api/OmsorgCore.Api.csproj +# Angewendet wird sie automatisch beim nächsten Start der API (siehe oben) — kein manueller +# `database update`-Schritt im Normalfall mehr nötig. + +# API starten (lädt appsettings.Development.json): +ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/OmsorgCore.Api +# → GET /api/health, POST /api/auth/login, GET/POST /api/employees (Bearer-Token nötig) +# Swagger UI unter /swagger im Development-Modus +``` + +## Verifiziert + +- `dotnet build` für alle 6 Projekte: grün. `dotnet test`: grün (u. a. `AuthServiceTests` mit Fake-Repositories für Login/Refresh-Rotation/Revoke). +- `dotnet tool run dotnet-ef database update` erfolgreich gegen echte PostgreSQL-Instanz ausgeführt (`InitialCreate` + `AddRefreshTokens`). +- API-Start mit `ASPNETCORE_ENVIRONMENT=Development`: `GET /api/health` → `{"status":"ok","databaseReachable":true}`. `POST /api/auth/login`/`refresh` mit falschen/unbekannten Credentials → 401, `POST /api/auth/logout` → 204. `GET /api/employees` ohne Token → 401 (Auth-Pipeline greift korrekt). +- `omsorgapp`s `authClient.cjs` erfolgreich gegen den laufenden Server getestet (Login/Refresh/Logout-Fehlerfälle). +- **Kompletter Login-Flow end-to-end mit echtem Postgres verifiziert:** Login mit `admin`/`abersicher` (Seed) → gültiges Token-Paar; `refresh` rotiert korrekt (neues Paar, alter Refresh-Token danach 401 bei Wiederverwendung); `GET /api/employees` mit frischem Access-Token → 200 (Administrator-Rolle hat volle Rechte über den Seed). +- **Session-Killswitch end-to-end verifiziert:** `GET /api/admin/sessions` liefert aktive Sessions; `POST /api/admin/sessions/revoke-all` → 204, danach liefert **derselbe, zuvor gültige Access-Token sofort 401** (nicht erst nach Ablauf) und der zugehörige Refresh-Token liefert bei `POST /api/auth/refresh` ebenfalls 401. Erneuter Login mit `admin`/`abersicher` funktioniert danach wieder normal. + +## Offene Punkte + +- Facility/Contract/Order/TimeEntry/Invoice haben noch keine Controller/Repositories/Services — nur Domain-Entitäten + DB-Konfiguration. Nächste Schritte folgen demselben Muster wie `Employee` (Repository-Interface in Application, Implementierung in Infrastructure, Service in Application, Controller in Api). +- Keine E-Mail-Verifizierung bei User-Anlage (Passwort-Reset per E-Mail ist fertig, siehe "Passwort-Reset / E-Mail-Versand" oben). +- `omsorgapp` spricht seit Kurzem gegen dieses Backend (Login-Screen + Refresh-Token-Session, siehe `omsorgapp/CLAUDE.md`) — `omsorgWeb` ist noch nicht angebunden. +- Kein Docker-/CI-Setup. + +## Die sechs Core-Objekte (Domain-Entitäten) + +Aus `REQUIREMENTS.md` Abschnitt 6 / Blueprint Kap. 19: **Mitarbeiter, Einrichtung, Vertrag, Auftrag, Zeiterfassung, Rechnung** — als `Employee`, `Facility`, `Contract`, `Order`, `TimeEntry`, `Invoice` in `src/OmsorgCore.Domain/Entities/` angelegt, mit Kernfeldern (nicht vollständig ausmodelliert). + +Verbindliche Regeln für das Datenmodell: +1. Jede Entität hat eine eindeutige `Guid Id` (siehe `Entity`-Basisklasse). +2. Beziehungen ausschließlich über Foreign Keys/IDs, keine redundante Texteingabe verwandter Daten. +3. Stammdaten nur an einer Stelle — kein Feld, das auch in `omsorgWeb` oder `omsorgapp` unabhängig gepflegt wird, sobald die Migration dorthin begonnen hat. +4. Änderungen an geschäftsrelevanten Daten müssen nachvollziehbar sein — `AuditableEntity` liefert `CreatedAt`/`UpdatedAt`; ein vollständiger Audit-Trail (wer hat was geändert) ist noch nicht gebaut. +5. Kein Hard-Delete für sensible/geschäftsrelevante Daten — Soft-Delete/Archivierung (noch nicht implementiert, bei Bedarf einbauen statt Datensätze zu löschen). +6. Berechtigungsprüfung auf Daten- und Funktionsebene (siehe Rechtesystem oben und Rechtematrix in `REQUIREMENTS.md` Abschnitt 7). +7. Rechnungen entstehen ausschließlich aus freigegebener Zeiterfassung (FR-ZE-3/FR-RE-1) — muss bei Ausbau von `TimeEntry`/`Invoice` auf Domain-/Application-Ebene erzwungen werden, nicht nur als UI-Regel im Client. + +## Event-Schicht (Engine) — Funktionsweise + +`IDomainEventDispatcher` (Singleton, In-Process) löst über den DI-Container alle registrierten `IDomainEventHandler` für ein Event auf und ruft sie auf. Beispiel: `EmployeesController.Create` ruft nach dem Speichern `_dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id))` auf, `EmployeeCreatedHandler` reagiert darauf (aktuell nur Logging). Neue Trigger aus `REQUIREMENTS.md` Abschnitt 4.10 (Krankmeldung, fehlender Tätigkeitsnachweis, Vertragsende, ...) folgen demselben Muster: Event-Klasse in `Engine/Events/`, Handler in `Engine/Handlers/`, Registrierung in `Engine/DependencyInjection.cs`, Dispatch-Aufruf an der Stelle im Api-Layer, wo das auslösende Ereignis passiert. + +**Wichtig:** `Application`-Services dispatchen bewusst *nicht* selbst (sie kennen `Engine` nicht, das würde die Abhängigkeitsrichtung verletzen) — das Dispatchen passiert im Api-Layer, der als einziger alle Schichten kennt. diff --git a/omsorgCore/CONFIGURATION.md b/omsorgCore/CONFIGURATION.md new file mode 100644 index 0000000..87ad1d9 --- /dev/null +++ b/omsorgCore/CONFIGURATION.md @@ -0,0 +1,87 @@ +# CONFIGURATION.md — omsorgCore + +Übersicht aller Konfigurationswerte des Backends: was ist einstellbar, was ist der Default, wie überschreibt man ihn. Ziel: **kein Wert, den ein Betreiber sinnvoll ändern könnte, steckt fest im Code.** + +## Wie Konfiguration geladen wird + +ASP.NET Core liest Konfiguration in dieser Reihenfolge (später gewinnt): + +1. `appsettings.json` — Defaults, die für jede Umgebung gelten. +2. `appsettings.{ASPNETCORE_ENVIRONMENT}.json` (z. B. `appsettings.Development.json`) — Overrides pro Umgebung. **Enthält keine echten Secrets**, nur `CHANGE_ME_...`-Platzhalter. +3. Umgebungsvariablen — verschachtelte Keys wie `Jwt:Secret` werden zu `Jwt__Secret` (doppelter Unterstrich statt Doppelpunkt). +4. In Development zusätzlich: `dotnet user-secrets` (lokal, nicht eingecheckt) — `dotnet user-secrets init` im `OmsorgCore.Api`-Projekt einmalig ausführen, dann z. B. `dotnet user-secrets set "ConnectionStrings:OmsorgCore" "..."`. + +**Secrets nie einchecken.** `Jwt:Secret`, `ConnectionStrings:OmsorgCore` und (sobald produktiv genutzt) `Seed:AdminPassword` haben in den eingecheckten `appsettings*.json`-Dateien nur Platzhalter — echte Werte kommen aus Umgebungsvariablen oder User-Secrets. + +## Auth / JWT + +| Key | Default | Env-Var | Beschreibung | +|---|---|---|---| +| `Jwt:Secret` | *(kein Default, Platzhalter in Dev)* | `Jwt__Secret` | Signaturschlüssel für Access-Tokens. Muss mind. 32 Zeichen, produktiv nur per Env-Var/Secret-Store. | +| `Jwt:Issuer` | `"OmsorgCore"` | `Jwt__Issuer` | JWT-`iss`-Claim, wird beim Validieren geprüft. | +| `Jwt:Audience` | `"OmsorgClients"` | `Jwt__Audience` | JWT-`aud`-Claim, wird beim Validieren geprüft. | +| `Jwt:ExpiryMinutes` | `60` | `Jwt__ExpiryMinutes` | Gültigkeitsdauer eines Access-Tokens. | +| `RefreshToken:ExpiryDays` | `60` | `RefreshToken__ExpiryDays` | Gültigkeitsdauer eines Refresh-Tokens (sliding — jede Nutzung/Rotation verlängert effektiv die Session). | +| `Auth:MaxLoginFailures` | `5` | `Auth__MaxLoginFailures` | Fehlversuche pro IP innerhalb von `Auth:LoginLockoutMinutes`, ab denen `POST /api/auth/login` mit `429` sperrt (zentral für alle Clients, siehe `AuthService.LoginAsync`/`LoginAttempt`). | +| `Auth:LoginLockoutMinutes` | `10` | `Auth__LoginLockoutMinutes` | Zeitfenster, in dem Fehlversuche gezählt werden, bevor die Sperre wieder abläuft. | + +## Passwort-Reset / E-Mail + +| Key | Default | Env-Var | Beschreibung | +|---|---|---|---| +| `Email:Provider` | `"Console"` | `Email__Provider` | Mail-Versandweg. `Console` schreibt Mails nur ins Log (sicherer Default). `Smtp` versendet echt über die Werte unten (implementiert über MailKit). | +| `Email:PinExpiryMinutes` | `5` | `Email__PinExpiryMinutes` | Gültigkeitsdauer des 6-stelligen PINs beim "Passwort vergessen"-Flow. | +| `Email:MaxAttempts` | `3` | `Email__MaxAttempts` | Maximale Anzahl Fehlversuche bei der PIN-Eingabe, bevor der Code invalidiert wird. | +| `Email:ResetTokenExpiryMinutes` | `10` | `Email__ResetTokenExpiryMinutes` | Gültigkeitsdauer des Reset-Tokens nach erfolgreicher PIN-Verifikation. | +| `Email:RequestCooldownSeconds` | `60` | `Email__RequestCooldownSeconds` | Mindestabstand zwischen zwei Reset-Anfragen desselben Users (Spam-Schutz). | + +**Ein SMTP-Server, mehrere Accounts:** die Verbindungsdaten (`Email:Smtp:*`) sind einmal gemeinsam konfiguriert, Login + Absenderidentität liegen pro benanntem Account unter `Email:Accounts::*` — so lässt sich z. B. ein Account exklusiv für Passwort-Reset-Mails führen, getrennt von einem künftigen allgemeinen Absender, ohne einen zweiten Server zu brauchen. + +| Key | Default | Env-Var | Beschreibung | +|---|---|---|---| +| `Email:Smtp:Host` | *(leer)* | `Email__Smtp__Host` | SMTP-Server-Adresse. Nur wirksam, wenn `Email:Provider` = `"Smtp"`. | +| `Email:Smtp:Port` | `587` | `Email__Smtp__Port` | SMTP-Port. | +| `Email:Smtp:EnableSsl` | `true` | `Email__Smtp__EnableSsl` | Ob StartTLS/SSL beim Verbindungsaufbau verwendet wird. | +| `Email:Accounts::Username` | *(leer, Platzhalter in Dev)* | `Email__Accounts____Username` | SMTP-Login-Username dieses Accounts. Secret — nie echten Wert einchecken. | +| `Email:Accounts::Password` | *(leer, Platzhalter in Dev)* | `Email__Accounts____Password` | SMTP-Login-Passwort dieses Accounts. Secret — produktiv nur per Env-Var/user-secrets, **nie** in `appsettings*.json` einchecken. | +| `Email:Accounts::SenderAddress` | *(leer)* | `Email__Accounts____SenderAddress` | Absenderadresse dieses Accounts. | +| `Email:Accounts::SenderDisplayName` | `"OMSORG"` | `Email__Accounts____SenderDisplayName` | Absendername dieses Accounts. | +| `Email:PasswordResetAccount` | `"PasswordReset"` | `Email__PasswordResetAccount` | Welcher Account-Name aus `Email:Accounts` für Passwort-Reset-Mails verwendet wird. Standardmäßig ist `"PasswordReset"` bereits als Account in `appsettings.json` angelegt (leer, muss per Env-Var/user-secrets befüllt werden). | +| `Email:PasswordResetTemplate:Subject` | `"Ihr OMSORG Passwort-Reset-Code"` | `Email__PasswordResetTemplate__Subject` | Betreff der Passwort-Reset-Mail. | +| `Email:PasswordResetTemplate:BodyTemplate` | siehe `appsettings.json` | `Email__PasswordResetTemplate__BodyTemplate` | Text der Passwort-Reset-Mail. Platzhalter im Format `$NAME$`: `$RESET_PIN$` (der 6-stellige Code) und `$RESET_PIN_EXPIRY_MINUTES$` (aus `Email:PinExpiryMinutes`). Ersetzung über `OmsorgCore.Email.EmailTemplateRenderer`. | +| `Email:UserInviteAccount` | `"UserInvite"` | `Email__UserInviteAccount` | Welcher Account-Name aus `Email:Accounts` für Account-Einladungsmails (neuer User-Account für einen Mitarbeiter) verwendet wird. | +| `Email:UserInviteTemplate:Subject` | `"Ihr OMSORG-Zugang"` | `Email__UserInviteTemplate__Subject` | Betreff der Einladungsmail. | +| `Email:UserInviteTemplate:BodyTemplate` | siehe `appsettings.json` | `Email__UserInviteTemplate__BodyTemplate` | Text der Einladungsmail. Platzhalter: `$INVITE_PIN$`, `$INVITE_PIN_EXPIRY_DAYS$`. Die Gültigkeitsdauer wird beim Anlegen des Accounts pro Einladung gewählt (`POST /api/users`, `PinValidityDays`, Default 7), nicht global konfiguriert — der Einladungs-PIN läuft über dieselbe `PasswordResetCode`-Tabelle wie der Passwort-vergessen-Flow, nur mit individuell längerer Gültigkeit statt der 5 Minuten aus `Email:PinExpiryMinutes`. | + +**Kein API-Endpoint zum Setzen dieser Werte:** SMTP-Zugangsdaten und Mail-Texte werden bewusst nie über eine vom Client erreichbare Route entgegengenommen oder ausgeliefert — nur über die üblichen serverseitigen Konfigurationswege oben. Ein `POST /api/admin/email/test-send` (siehe `AdminEmailController`) löst lediglich den Versand einer festen Testmail über die bereits konfigurierte Verbindung aus, liefert aber nie Host/Username/Passwort an den Client zurück. + +## Seed (nur Development) + +| Key | Default | Env-Var | Beschreibung | +|---|---|---|---| +| `Seed:AdminUsername` | `"admin"` | `Seed__AdminUsername` | Username des automatisch angelegten Standard-Admin-Users. | +| `Seed:AdminPassword` | `"abersicher"` | `Seed__AdminPassword` | Passwort des Standard-Admin-Users. | + +Der Seed läuft nur, wenn `ASPNETCORE_ENVIRONMENT=Development` **und** noch kein `User` in der DB existiert (idempotent, siehe `src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs`). In Produktion läuft er nicht — dort braucht es einen echten User-Anlage-Flow (noch nicht gebaut, siehe `omsorgCore/CLAUDE.md`, "Offene Punkte"). + +## Datenbank + +| Key | Default | Env-Var | Beschreibung | +|---|---|---|---| +| `ConnectionStrings:OmsorgCore` | *(kein Default, Platzhalter in Dev)* | `ConnectionStrings__OmsorgCore` | Postgres-Connection-String (`Host=...;Port=5432;Database=omsorg_core;Username=...;Password=...`). | + +## Logging / Hosting + +| Key | Default | Env-Var | Beschreibung | +|---|---|---|---| +| `Logging:LogLevel:Default` | `"Information"` | `Logging__LogLevel__Default` | Standard-Log-Level. | +| `Logging:LogLevel:Microsoft.AspNetCore` | `"Warning"` | `Logging__LogLevel__Microsoft.AspNetCore` | Log-Level für das ASP.NET-Core-Framework selbst. | +| `AllowedHosts` | `"*"` | `AllowedHosts` | Host-Header-Filter von ASP.NET Core. | + +## Bewusst nicht konfigurierbar + +Diese Werte sind absichtlich fest im Code und **keine** Konfigurationslücke: + +- **`ModuleType`, `PermissionAction`, `PermissionEffect`** (`src/OmsorgCore.Domain/Enums/`) — die Rechtematrix-Bausteine. Compile-Time-Domänenkonzepte: ein neues Modul oder eine neue Aktion braucht ohnehin neuen Controller-Code, ein Config-Wert würde hier nur Scheinflexibilität vortäuschen. +- **JWT-Validierungs-Invarianten** in `Program.cs` (`ValidateIssuer/Audience/Lifetime/IssuerSigningKey = true`) — Sicherheits-Grundannahmen, kein Betriebsparameter. +- **Passwort-Hashing-Algorithmus** (`PasswordHasher`, ASP.NET-Identity-Standard-PBKDF2-Iterationszahl) — bewusst der Framework-Default, kein eigenes Krypto-Rad. \ No newline at end of file diff --git a/omsorgCore/OmsorgCore.sln b/omsorgCore/OmsorgCore.sln new file mode 100644 index 0000000..947aed0 --- /dev/null +++ b/omsorgCore/OmsorgCore.sln @@ -0,0 +1,71 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{56212531-1A9C-478F-AE76-FF2EBF4739A2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Domain", "src\OmsorgCore.Domain\OmsorgCore.Domain.csproj", "{6609F28E-F062-4A28-B161-D9A76A498987}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Application", "src\OmsorgCore.Application\OmsorgCore.Application.csproj", "{979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Infrastructure", "src\OmsorgCore.Infrastructure\OmsorgCore.Infrastructure.csproj", "{3972F31A-A0F7-4A12-882B-FB60BE774936}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Engine", "src\OmsorgCore.Engine\OmsorgCore.Engine.csproj", "{4D108EE6-C808-4543-AC6B-1B20D0900ED3}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Api", "src\OmsorgCore.Api\OmsorgCore.Api.csproj", "{8DB4399D-945C-4064-AF9D-881B6BD229B4}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{CBBB0229-FAD9-4443-BA91-DFC04FDA8913}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Tests", "tests\OmsorgCore.Tests\OmsorgCore.Tests.csproj", "{5BB394B3-7350-4A6E-808F-F300742F2EEC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OmsorgCore.Email", "src\OmsorgCore.Email\OmsorgCore.Email.csproj", "{6B58D108-8D89-4D8F-A262-309D0BCF674F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {6609F28E-F062-4A28-B161-D9A76A498987}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6609F28E-F062-4A28-B161-D9A76A498987}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6609F28E-F062-4A28-B161-D9A76A498987}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6609F28E-F062-4A28-B161-D9A76A498987}.Release|Any CPU.Build.0 = Release|Any CPU + {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9}.Release|Any CPU.Build.0 = Release|Any CPU + {3972F31A-A0F7-4A12-882B-FB60BE774936}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3972F31A-A0F7-4A12-882B-FB60BE774936}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3972F31A-A0F7-4A12-882B-FB60BE774936}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3972F31A-A0F7-4A12-882B-FB60BE774936}.Release|Any CPU.Build.0 = Release|Any CPU + {4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D108EE6-C808-4543-AC6B-1B20D0900ED3}.Release|Any CPU.Build.0 = Release|Any CPU + {8DB4399D-945C-4064-AF9D-881B6BD229B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8DB4399D-945C-4064-AF9D-881B6BD229B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8DB4399D-945C-4064-AF9D-881B6BD229B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8DB4399D-945C-4064-AF9D-881B6BD229B4}.Release|Any CPU.Build.0 = Release|Any CPU + {5BB394B3-7350-4A6E-808F-F300742F2EEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5BB394B3-7350-4A6E-808F-F300742F2EEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5BB394B3-7350-4A6E-808F-F300742F2EEC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5BB394B3-7350-4A6E-808F-F300742F2EEC}.Release|Any CPU.Build.0 = Release|Any CPU + {6B58D108-8D89-4D8F-A262-309D0BCF674F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6B58D108-8D89-4D8F-A262-309D0BCF674F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6B58D108-8D89-4D8F-A262-309D0BCF674F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6B58D108-8D89-4D8F-A262-309D0BCF674F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6609F28E-F062-4A28-B161-D9A76A498987} = {56212531-1A9C-478F-AE76-FF2EBF4739A2} + {979A8C32-B0C6-461A-8BB4-D7EB247CAAB9} = {56212531-1A9C-478F-AE76-FF2EBF4739A2} + {3972F31A-A0F7-4A12-882B-FB60BE774936} = {56212531-1A9C-478F-AE76-FF2EBF4739A2} + {4D108EE6-C808-4543-AC6B-1B20D0900ED3} = {56212531-1A9C-478F-AE76-FF2EBF4739A2} + {8DB4399D-945C-4064-AF9D-881B6BD229B4} = {56212531-1A9C-478F-AE76-FF2EBF4739A2} + {5BB394B3-7350-4A6E-808F-F300742F2EEC} = {CBBB0229-FAD9-4443-BA91-DFC04FDA8913} + {6B58D108-8D89-4D8F-A262-309D0BCF674F} = {56212531-1A9C-478F-AE76-FF2EBF4739A2} + EndGlobalSection +EndGlobal diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ChangePasswordRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ChangePasswordRequest.cs new file mode 100644 index 0000000..4432530 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ChangePasswordRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record ChangePasswordRequest(string CurrentPassword, string NewPassword); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeRequest.cs new file mode 100644 index 0000000..b0e9142 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateEmployeeRequest.cs @@ -0,0 +1,19 @@ +namespace OmsorgCore.Api.Contracts; + +public record CreateEmployeeRequest( + string FirstName, + string LastName, + DateOnly? DateOfBirth, + string? Street, + string? PostalCode, + string? City, + string? Country, + string? PhoneNumber, + string? Email, + DateOnly? EntryDate, + DateOnly? ExitDate, + string? EmergencyContactName, + string? EmergencyContactPhone, + string? EmergencyContactRelation, + string? EmploymentType, + string? Qualification); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/CreateRoleRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateRoleRequest.cs new file mode 100644 index 0000000..7e3f35d --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateRoleRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record CreateRoleRequest(string Name); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/CreateUserRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateUserRequest.cs new file mode 100644 index 0000000..fb49e16 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/CreateUserRequest.cs @@ -0,0 +1,10 @@ +namespace OmsorgCore.Api.Contracts; + +/// Mode: "Invite" (Einladungsmail mit PIN, Mitarbeiter setzt eigenes Passwort) oder "Direct" (Admin vergibt InitialPassword direkt, MustChangePassword erzwingt Wechsel). +public record CreateUserRequest( + Guid EmployeeId, + string Username, + Guid RoleId, + string Mode, + string? InitialPassword, + int? PinValidityDays); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeResponse.cs new file mode 100644 index 0000000..da43759 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/EmployeeResponse.cs @@ -0,0 +1,22 @@ +namespace OmsorgCore.Api.Contracts; + +public record EmployeeResponse( + Guid Id, + string FirstName, + string LastName, + string Status, + DateOnly? DateOfBirth, + string? Street, + string? PostalCode, + string? City, + string? Country, + string? PhoneNumber, + string? Email, + DateOnly? EntryDate, + DateOnly? ExitDate, + string? EmergencyContactName, + string? EmergencyContactPhone, + string? EmergencyContactRelation, + string? EmploymentType, + string? Qualification, + string? AvatarFileName); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestRequest.cs new file mode 100644 index 0000000..1966c7b --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record ForgotPasswordRequestRequest(string Username); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestResponse.cs new file mode 100644 index 0000000..f74578a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordRequestResponse.cs @@ -0,0 +1,4 @@ +namespace OmsorgCore.Api.Contracts; + +/// "sent" | "cannot_reset" - siehe AuthController.ForgotPasswordRequest für die Anti-Enumeration-Abwägung. +public record ForgotPasswordRequestResponse(string Status); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordResetRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordResetRequest.cs new file mode 100644 index 0000000..66752ab --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordResetRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record ForgotPasswordResetRequest(string ResetToken, string NewPassword); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyRequest.cs new file mode 100644 index 0000000..96b8d27 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record ForgotPasswordVerifyRequest(string Username, string Pin); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyResponse.cs new file mode 100644 index 0000000..49542fb --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ForgotPasswordVerifyResponse.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record ForgotPasswordVerifyResponse(string ResetToken); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/LoginRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/LoginRequest.cs new file mode 100644 index 0000000..51ff6df --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/LoginRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record LoginRequest(string Username, string Password); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/LoginResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/LoginResponse.cs new file mode 100644 index 0000000..f817bc7 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/LoginResponse.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt, bool MustChangePassword); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/LogoutRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/LogoutRequest.cs new file mode 100644 index 0000000..8a3a554 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/LogoutRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record LogoutRequest(string RefreshToken); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/MeResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/MeResponse.cs new file mode 100644 index 0000000..d3a5aed --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/MeResponse.cs @@ -0,0 +1,13 @@ +namespace OmsorgCore.Api.Contracts; + +public record MeResponse( + Guid UserId, + string Username, + string Role, + IReadOnlyList Permissions, + Guid? EmployeeId, + string? FirstName, + string? LastName, + string? Email, + string? PhoneNumber, + string? AvatarFileName); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/PagedResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/PagedResponse.cs new file mode 100644 index 0000000..22c37de --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/PagedResponse.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record PagedResponse(IReadOnlyList Items, int TotalCount, int Page, int PageSize); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/PasswordResetTemplateResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/PasswordResetTemplateResponse.cs new file mode 100644 index 0000000..9b63bcd --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/PasswordResetTemplateResponse.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record PasswordResetTemplateResponse(string Subject, string BodyTemplate); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/PermissionDto.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/PermissionDto.cs new file mode 100644 index 0000000..120e639 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/PermissionDto.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record PermissionDto(string Module, string Action); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/RefreshRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/RefreshRequest.cs new file mode 100644 index 0000000..c580648 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/RefreshRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record RefreshRequest(string RefreshToken); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/ResetUserPasswordRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/ResetUserPasswordRequest.cs new file mode 100644 index 0000000..3a263a0 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/ResetUserPasswordRequest.cs @@ -0,0 +1,4 @@ +namespace OmsorgCore.Api.Contracts; + +/// Mode: "Invite" (neuer Einladungs-PIN) oder "Direct" (Admin vergibt InitialPassword direkt, MustChangePassword erzwingt Wechsel). +public record ResetUserPasswordRequest(string Mode, string? InitialPassword, int? PinValidityDays); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/RoleResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/RoleResponse.cs new file mode 100644 index 0000000..16c036a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/RoleResponse.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record RoleResponse(Guid Id, string Name); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/SendTestEmailRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/SendTestEmailRequest.cs new file mode 100644 index 0000000..fc288b2 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/SendTestEmailRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record SendTestEmailRequest(string ToAddress, string? Subject, string? Body); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/SessionResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/SessionResponse.cs new file mode 100644 index 0000000..83379e8 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/SessionResponse.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record SessionResponse(Guid Id, string Username, DateTime CreatedAt, DateTime ExpiresAt); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeRequest.cs new file mode 100644 index 0000000..70267c2 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateEmployeeRequest.cs @@ -0,0 +1,21 @@ +namespace OmsorgCore.Api.Contracts; + +public record UpdateEmployeeRequest( + string FirstName, + string LastName, + string Status, + DateOnly? DateOfBirth, + string? Street, + string? PostalCode, + string? City, + string? Country, + string? PhoneNumber, + string? Email, + DateOnly? EntryDate, + DateOnly? ExitDate, + string? EmergencyContactName, + string? EmergencyContactPhone, + string? EmergencyContactRelation, + string? EmploymentType, + string? Qualification, + string? AvatarFileName); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateUserRequest.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateUserRequest.cs new file mode 100644 index 0000000..8f840c2 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UpdateUserRequest.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record UpdateUserRequest(Guid RoleId, bool IsActive); diff --git a/omsorgCore/src/OmsorgCore.Api/Contracts/UserResponse.cs b/omsorgCore/src/OmsorgCore.Api/Contracts/UserResponse.cs new file mode 100644 index 0000000..c3712f5 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Contracts/UserResponse.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Api.Contracts; + +public record UserResponse(Guid Id, string Username, Guid? EmployeeId, string RoleName, bool IsActive, bool MustChangePassword); diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/AdminEmailController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/AdminEmailController.cs new file mode 100644 index 0000000..726d1a5 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Controllers/AdminEmailController.cs @@ -0,0 +1,67 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using OmsorgCore.Api.Contracts; +using OmsorgCore.Api.Security; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Enums; +using OmsorgCore.Email; + +namespace OmsorgCore.Api.Controllers; + +/// +/// Debug-/Admin-Funktion, um den konfigurierten Mail-Versand zu testen (siehe omsorgapp Debug-Seite). +/// Sendet nur eine Testmail über den registrierten IEmailSender, mit vom Admin editierbarem Betreff/Text +/// (vorbefüllt aus der echten Passwort-Reset-Vorlage) - SMTP-Zugangsdaten selbst verlassen den Server +/// dabei zu keinem Zeitpunkt. +/// +[ApiController] +[Authorize] +[Route("api/admin/email")] +public class AdminEmailController : ControllerBase +{ + private readonly IEmailSender _emailSender; + private readonly EmailOptions _emailOptions; + + public AdminEmailController(IEmailSender emailSender, IOptions emailOptions) + { + _emailSender = emailSender; + _emailOptions = emailOptions.Value; + } + + [HttpGet("password-reset-template")] + [RequirePermission(ModuleType.UserManagement, PermissionAction.View)] + public ActionResult GetPasswordResetTemplate() + { + var template = _emailOptions.PasswordResetTemplate; + return Ok(new PasswordResetTemplateResponse(template.Subject, template.BodyTemplate)); + } + + [HttpPost("test-send")] + [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)] + public async Task TestSend(SendTestEmailRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.ToAddress)) + { + return BadRequest(new { error = "to_address_required" }); + } + + var template = _emailOptions.PasswordResetTemplate; + var subject = string.IsNullOrWhiteSpace(request.Subject) ? template.Subject : request.Subject; + var body = string.IsNullOrWhiteSpace(request.Body) ? template.BodyTemplate : request.Body; + + // Testmail nutzt echte Platzhalter-Ersetzung (mit Beispiel-Werten), damit sie genau zeigt, + // was ein Nutzer im echten Reset-Flow bekommen würde. + var values = new Dictionary + { + ["RESET_PIN"] = "123456", + ["RESET_PIN_EXPIRY_MINUTES"] = _emailOptions.PinExpiryMinutes.ToString() + }; + subject = EmailTemplateRenderer.Render(subject, values); + body = EmailTemplateRenderer.Render(body, values); + + await _emailSender.SendAsync(new EmailMessage(request.ToAddress, subject, body), cancellationToken); + + return NoContent(); + } +} diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/AdminSessionsController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/AdminSessionsController.cs new file mode 100644 index 0000000..a655442 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Controllers/AdminSessionsController.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OmsorgCore.Api.Contracts; +using OmsorgCore.Api.Security; +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Enums; + +namespace OmsorgCore.Api.Controllers; + +/// +/// Debug-/Admin-Sicht auf aktive Sessions (siehe omsorgapp Debug-Seite). Nur für Rollen mit +/// UserManagement-Rechten (per Default: Administrator, siehe DbSeeder). +/// +[ApiController] +[Authorize] +[Route("api/admin/sessions")] +public class AdminSessionsController : ControllerBase +{ + private readonly ISessionAdminService _sessionAdminService; + + public AdminSessionsController(ISessionAdminService sessionAdminService) + { + _sessionAdminService = sessionAdminService; + } + + [HttpGet] + [RequirePermission(ModuleType.UserManagement, PermissionAction.View)] + public async Task>> GetAll(CancellationToken cancellationToken) + { + var sessions = await _sessionAdminService.GetActiveSessionsAsync(cancellationToken); + return Ok(sessions.Select(s => new SessionResponse(s.Id, s.Username, s.CreatedAt, s.ExpiresAt))); + } + + [HttpPost("{id:guid}/revoke")] + [RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)] + public async Task Revoke(Guid id, CancellationToken cancellationToken) + { + await _sessionAdminService.RevokeSessionAsync(id, cancellationToken); + return NoContent(); + } + + [HttpPost("revoke-all")] + [RequirePermission(ModuleType.UserManagement, PermissionAction.Delete)] + public async Task RevokeAll(CancellationToken cancellationToken) + { + await _sessionAdminService.RevokeAllSessionsAsync(cancellationToken); + return NoContent(); + } +} diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/AuthController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..aae9631 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Controllers/AuthController.cs @@ -0,0 +1,163 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OmsorgCore.Api.Contracts; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Application.Services; +using OmsorgCore.Engine.Events; + +namespace OmsorgCore.Api.Controllers; + +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + private readonly ICurrentUserService _currentUserService; + private readonly IPasswordResetService _passwordResetService; + private readonly IUserService _userService; + private readonly IDomainEventDispatcher _dispatcher; + + public AuthController( + IAuthService authService, + ICurrentUserService currentUserService, + IPasswordResetService passwordResetService, + IUserService userService, + IDomainEventDispatcher dispatcher) + { + _authService = authService; + _currentUserService = currentUserService; + _passwordResetService = passwordResetService; + _userService = userService; + _dispatcher = dispatcher; + } + + [HttpPost("login")] + public async Task> Login(LoginRequest request, CancellationToken cancellationToken) + { + var ipAddress = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + var result = await _authService.LoginAsync(request.Username, request.Password, ipAddress, cancellationToken); + + if (result.IsLockedOut) + { + return StatusCode(StatusCodes.Status429TooManyRequests, new { error = "too_many_attempts" }); + } + + 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)); + } + + [HttpPost("refresh")] + public async Task> Refresh(RefreshRequest request, CancellationToken cancellationToken) + { + var result = await _authService.RefreshAsync(request.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)); + } + + [HttpPost("logout")] + public async Task Logout(LogoutRequest request, CancellationToken cancellationToken) + { + await _authService.RevokeAsync(request.RefreshToken, cancellationToken); + return NoContent(); + } + + [HttpGet("me")] + [Authorize] + public async Task> Me(CancellationToken cancellationToken) + { + if (_currentUserService.UserId is not { } userId) + { + return Unauthorized(); + } + + var profile = await _authService.GetProfileAsync(userId, cancellationToken); + if (profile is null) + { + return Unauthorized(); + } + + var permissions = profile.Permissions + .Select(p => new PermissionDto(p.Module.ToString(), p.Action.ToString())) + .ToList(); + + return Ok(new MeResponse( + profile.UserId, + profile.Username, + profile.RoleName, + permissions, + profile.EmployeeId, + profile.FirstName, + profile.LastName, + profile.Email, + profile.PhoneNumber, + profile.AvatarFileName)); + } + + [HttpPost("change-password")] + [Authorize] + public async Task ChangePassword(ChangePasswordRequest request, CancellationToken cancellationToken) + { + if (_currentUserService.UserId is not { } userId) + { + return Unauthorized(); + } + + if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8) + { + return BadRequest("NewPassword muss mindestens 8 Zeichen lang sein."); + } + + var success = await _userService.ChangeOwnPasswordAsync(userId, request.CurrentPassword, request.NewPassword, cancellationToken); + return success ? NoContent() : Unauthorized(); + } + + [HttpPost("forgot-password/request")] + public async Task> ForgotPasswordRequest( + ForgotPasswordRequestRequest request, CancellationToken cancellationToken) + { + 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); + } + + var status = result.Status == PasswordResetRequestStatus.Sent ? "sent" : "cannot_reset"; + return Ok(new ForgotPasswordRequestResponse(status)); + } + + [HttpPost("forgot-password/verify")] + public async Task> ForgotPasswordVerify( + ForgotPasswordVerifyRequest request, CancellationToken cancellationToken) + { + var result = await _passwordResetService.VerifyCodeAsync(request.Username, request.Pin, cancellationToken); + if (!result.Success || result.ResetToken is null) + { + return Unauthorized(new { error = result.FailureReason ?? "invalid_or_expired" }); + } + + return Ok(new ForgotPasswordVerifyResponse(result.ResetToken)); + } + + [HttpPost("forgot-password/reset")] + public async Task ForgotPasswordReset( + ForgotPasswordResetRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.NewPassword)) + { + return Unauthorized(); + } + + var success = await _passwordResetService.ResetPasswordAsync( + request.ResetToken, request.NewPassword, cancellationToken); + return success ? NoContent() : Unauthorized(); + } +} diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeesController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeesController.cs new file mode 100644 index 0000000..a06104e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Controllers/EmployeesController.cs @@ -0,0 +1,289 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OmsorgCore.Api.Contracts; +using OmsorgCore.Api.Security; +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Domain.Enums; +using OmsorgCore.Engine.Events; + +namespace OmsorgCore.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/employees")] +public class EmployeesController : ControllerBase +{ + private static readonly string[] AllowedEmploymentTypes = + { "Vollzeit", "Teilzeit", "Minijob", "Aushilfe", "Praktikant", "Freiberuflich" }; + + private readonly IEmployeeService _employeeService; + private readonly IDomainEventDispatcher _dispatcher; + + public EmployeesController(IEmployeeService employeeService, IDomainEventDispatcher dispatcher) + { + _employeeService = employeeService; + _dispatcher = dispatcher; + } + + [HttpGet] + [RequirePermission(ModuleType.Employees, PermissionAction.View)] + public async Task>> GetAll( + [FromQuery] string? search, + [FromQuery] string? status, + [FromQuery] string? employmentType, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20, + CancellationToken cancellationToken = default) + { + if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType)) + { + return BadRequest($"employmentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}."); + } + + page = Math.Max(page, 1); + pageSize = Math.Clamp(pageSize, 1, 100); + + var (items, totalCount) = await _employeeService.GetPagedAsync(search, status, employmentType, page, pageSize, cancellationToken); + return Ok(new PagedResponse(items.Select(ToResponse).ToList(), totalCount, page, pageSize)); + } + + [HttpGet("{id:guid}")] + [RequirePermission(ModuleType.Employees, PermissionAction.View)] + public async Task> GetById(Guid id, CancellationToken cancellationToken) + { + var employee = await _employeeService.GetByIdAsync(id, cancellationToken); + return employee is null ? NotFound() : Ok(ToResponse(employee)); + } + + [HttpPost] + [RequirePermission(ModuleType.Employees, PermissionAction.Create)] + public async Task> Create(CreateEmployeeRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.FirstName) || request.FirstName.Length > 200) + { + return BadRequest("FirstName ist erforderlich und darf maximal 200 Zeichen lang sein."); + } + + if (string.IsNullOrWhiteSpace(request.LastName) || request.LastName.Length > 200) + { + return BadRequest("LastName ist erforderlich und darf maximal 200 Zeichen lang sein."); + } + + if (request.PhoneNumber is { Length: > 50 }) + { + return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein."); + } + + if (request.Email is { Length: > 200 }) + { + return BadRequest("Email darf maximal 200 Zeichen lang sein."); + } + + if (request.EntryDate is not null && request.ExitDate is not null && request.ExitDate < request.EntryDate) + { + return BadRequest("ExitDate darf nicht vor EntryDate liegen."); + } + + var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country); + if (addressError is not null) + { + return BadRequest(addressError); + } + + var stammdatenError = ValidateStammdatenFields( + request.EmergencyContactName, + request.EmergencyContactPhone, + request.EmergencyContactRelation, + request.EmploymentType, + request.Qualification); + if (stammdatenError is not null) + { + return BadRequest(stammdatenError); + } + + var employee = new Employee + { + FirstName = request.FirstName, + LastName = request.LastName, + DateOfBirth = request.DateOfBirth, + Street = request.Street, + PostalCode = request.PostalCode, + City = request.City, + Country = request.Country, + PhoneNumber = request.PhoneNumber, + Email = request.Email, + EntryDate = request.EntryDate, + ExitDate = request.ExitDate, + EmergencyContactName = request.EmergencyContactName, + EmergencyContactPhone = request.EmergencyContactPhone, + EmergencyContactRelation = request.EmergencyContactRelation, + EmploymentType = request.EmploymentType, + Qualification = request.Qualification + }; + + var created = await _employeeService.CreateAsync(employee, cancellationToken); + await _dispatcher.DispatchAsync(new EmployeeCreatedEvent(created.Id), cancellationToken); + + return CreatedAtAction(nameof(GetById), new { id = created.Id }, ToResponse(created)); + } + + [HttpPut("{id:guid}")] + [RequirePermission(ModuleType.Employees, PermissionAction.Edit)] + public async Task> Update(Guid id, UpdateEmployeeRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.FirstName) || request.FirstName.Length > 200) + { + return BadRequest("FirstName ist erforderlich und darf maximal 200 Zeichen lang sein."); + } + + if (string.IsNullOrWhiteSpace(request.LastName) || request.LastName.Length > 200) + { + return BadRequest("LastName ist erforderlich und darf maximal 200 Zeichen lang sein."); + } + + if (string.IsNullOrWhiteSpace(request.Status) || request.Status.Length > 50) + { + return BadRequest("Status ist erforderlich und darf maximal 50 Zeichen lang sein."); + } + + if (request.PhoneNumber is { Length: > 50 }) + { + return BadRequest("PhoneNumber darf maximal 50 Zeichen lang sein."); + } + + if (request.Email is { Length: > 200 }) + { + return BadRequest("Email darf maximal 200 Zeichen lang sein."); + } + + if (request.EntryDate is not null && request.ExitDate is not null && request.ExitDate < request.EntryDate) + { + return BadRequest("ExitDate darf nicht vor EntryDate liegen."); + } + + var addressError = ValidateAddressFields(request.Street, request.PostalCode, request.City, request.Country); + if (addressError is not null) + { + return BadRequest(addressError); + } + + var stammdatenError = ValidateStammdatenFields( + request.EmergencyContactName, + request.EmergencyContactPhone, + request.EmergencyContactRelation, + request.EmploymentType, + request.Qualification); + if (stammdatenError is not null) + { + return BadRequest(stammdatenError); + } + + var updates = new Employee + { + FirstName = request.FirstName, + LastName = request.LastName, + Status = request.Status, + DateOfBirth = request.DateOfBirth, + Street = request.Street, + PostalCode = request.PostalCode, + City = request.City, + Country = request.Country, + PhoneNumber = request.PhoneNumber, + Email = request.Email, + EntryDate = request.EntryDate, + ExitDate = request.ExitDate, + EmergencyContactName = request.EmergencyContactName, + EmergencyContactPhone = request.EmergencyContactPhone, + EmergencyContactRelation = request.EmergencyContactRelation, + EmploymentType = request.EmploymentType, + Qualification = request.Qualification, + AvatarFileName = request.AvatarFileName + }; + + var updated = await _employeeService.UpdateAsync(id, updates, cancellationToken); + return updated is null ? NotFound() : Ok(ToResponse(updated)); + } + + private static string? ValidateAddressFields(string? street, string? postalCode, string? city, string? country) + { + if (street is { Length: > 200 }) + { + return "Street darf maximal 200 Zeichen lang sein."; + } + + if (postalCode is { Length: > 10 }) + { + return "PostalCode darf maximal 10 Zeichen lang sein."; + } + + if (city is { Length: > 100 }) + { + return "City darf maximal 100 Zeichen lang sein."; + } + + if (country is { Length: > 100 }) + { + return "Country darf maximal 100 Zeichen lang sein."; + } + + return null; + } + + private static string? ValidateStammdatenFields( + string? emergencyContactName, + string? emergencyContactPhone, + string? emergencyContactRelation, + string? employmentType, + string? qualification) + { + if (emergencyContactName is { Length: > 200 }) + { + return "EmergencyContactName darf maximal 200 Zeichen lang sein."; + } + + if (emergencyContactPhone is { Length: > 50 }) + { + return "EmergencyContactPhone darf maximal 50 Zeichen lang sein."; + } + + if (emergencyContactRelation is { Length: > 100 }) + { + return "EmergencyContactRelation darf maximal 100 Zeichen lang sein."; + } + + if (employmentType is not null && !AllowedEmploymentTypes.Contains(employmentType)) + { + return $"EmploymentType muss einer der folgenden Werte sein: {string.Join(", ", AllowedEmploymentTypes)}."; + } + + if (qualification is { Length: > 500 }) + { + return "Qualification darf maximal 500 Zeichen lang sein."; + } + + return null; + } + + private static EmployeeResponse ToResponse(Employee employee) + => new( + employee.Id, + employee.FirstName, + employee.LastName, + employee.Status, + employee.DateOfBirth, + employee.Street, + employee.PostalCode, + employee.City, + employee.Country, + employee.PhoneNumber, + employee.Email, + employee.EntryDate, + employee.ExitDate, + employee.EmergencyContactName, + employee.EmergencyContactPhone, + employee.EmergencyContactRelation, + employee.EmploymentType, + employee.Qualification, + employee.AvatarFileName); +} diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/HealthController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/HealthController.cs new file mode 100644 index 0000000..09d3d06 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Controllers/HealthController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Infrastructure.Persistence; + +namespace OmsorgCore.Api.Controllers; + +[ApiController] +[Route("api/health")] +public class HealthController : ControllerBase +{ + private readonly OmsorgCoreDbContext _db; + + public HealthController(OmsorgCoreDbContext db) + { + _db = db; + } + + [HttpGet] + public async Task Get(CancellationToken cancellationToken) + { + bool databaseReachable; + try + { + databaseReachable = await _db.Database.CanConnectAsync(cancellationToken); + } + catch + { + databaseReachable = false; + } + + return Ok(new { status = "ok", databaseReachable }); + } +} diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs new file mode 100644 index 0000000..b733744 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Controllers/RolesController.cs @@ -0,0 +1,51 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OmsorgCore.Api.Contracts; +using OmsorgCore.Api.Security; +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Enums; + +namespace OmsorgCore.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/roles")] +public class RolesController : ControllerBase +{ + private readonly IRoleService _roleService; + + public RolesController(IRoleService roleService) + { + _roleService = roleService; + } + + [HttpGet] + [RequirePermission(ModuleType.UserManagement, PermissionAction.View)] + public async Task>> GetAll(CancellationToken cancellationToken) + { + var roles = await _roleService.GetAllAsync(cancellationToken); + return Ok(roles.Select(r => new RoleResponse(r.Id, r.Name)).ToList()); + } + + [HttpPost] + [RequirePermission(ModuleType.UserManagement, PermissionAction.Create)] + public async Task> Create(CreateRoleRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100) + { + return BadRequest("Name ist erforderlich und darf maximal 100 Zeichen lang sein."); + } + + var result = await _roleService.CreateAsync(request.Name, cancellationToken); + if (!result.Success) + { + return result.FailureReason switch + { + CreateRoleFailureReason.NameTaken => Conflict("Diese Rolle existiert bereits."), + _ => BadRequest("Name ist erforderlich.") + }; + } + + return CreatedAtAction(nameof(GetAll), new RoleResponse(result.Role!.Id, result.Role.Name)); + } +} diff --git a/omsorgCore/src/OmsorgCore.Api/Controllers/UsersController.cs b/omsorgCore/src/OmsorgCore.Api/Controllers/UsersController.cs new file mode 100644 index 0000000..a7af347 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Controllers/UsersController.cs @@ -0,0 +1,167 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OmsorgCore.Api.Contracts; +using OmsorgCore.Api.Security; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Enums; +using OmsorgCore.Engine.Events; + +namespace OmsorgCore.Api.Controllers; + +[ApiController] +[Authorize] +[Route("api/users")] +public class UsersController : ControllerBase +{ + private const int MinInitialPasswordLength = 8; + private const int DefaultPinValidityDays = 7; + private const int MinPinValidityDays = 1; + private const int MaxPinValidityDays = 30; + + private readonly IUserService _userService; + private readonly IDomainEventDispatcher _dispatcher; + + public UsersController(IUserService userService, IDomainEventDispatcher dispatcher) + { + _userService = userService; + _dispatcher = dispatcher; + } + + [HttpGet] + [RequirePermission(ModuleType.UserManagement, PermissionAction.View)] + public async Task>> GetAll(CancellationToken cancellationToken) + { + var users = await _userService.GetAllAsync(cancellationToken); + return Ok(users.Select(ToResponse).ToList()); + } + + [HttpPost] + [RequirePermission(ModuleType.UserManagement, PermissionAction.Create)] + public async Task> Create(CreateUserRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Username) || request.Username.Length > 100) + { + return BadRequest("Username ist erforderlich und darf maximal 100 Zeichen lang sein."); + } + + var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword); + if (parseError is not null) + { + return BadRequest(parseError); + } + + var result = await _userService.CreateForEmployeeAsync( + request.EmployeeId, request.Username, request.RoleId, mode, + request.InitialPassword, pinValidity, cancellationToken); + + if (!result.Success) + { + return result.FailureReason switch + { + CreateUserFailureReason.EmployeeNotFound => NotFound("Mitarbeiter nicht gefunden."), + CreateUserFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."), + CreateUserFailureReason.EmployeeAlreadyHasAccount => Conflict("Dieser Mitarbeiter hat bereits ein Konto."), + CreateUserFailureReason.UsernameTaken => Conflict("Dieser Username ist bereits vergeben."), + CreateUserFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."), + CreateUserFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."), + _ => BadRequest() + }; + } + + if (mode == UserCreationMode.Invite && result.Email is not null && result.RawInvitePin is not null) + { + await _dispatcher.DispatchAsync( + new UserInvitedEvent(result.Email, result.RawInvitePin, (int)pinValidity!.Value.TotalDays), + cancellationToken); + } + + return CreatedAtAction(nameof(GetAll), new UserResponse( + result.UserId!.Value, request.Username, request.EmployeeId, + result.RoleName!, IsActive: true, MustChangePassword: mode == UserCreationMode.Direct)); + } + + [HttpPost("{id:guid}/reset-password")] + [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)] + public async Task ResetPassword(Guid id, ResetUserPasswordRequest request, CancellationToken cancellationToken) + { + var (parseError, mode, pinValidity) = ParseModeAndPinValidity(request.Mode, request.PinValidityDays, request.InitialPassword); + if (parseError is not null) + { + return BadRequest(parseError); + } + + var result = await _userService.AdminResetPasswordAsync(id, mode, request.InitialPassword, pinValidity, cancellationToken); + + if (!result.Success) + { + return result.FailureReason switch + { + AdminResetPasswordFailureReason.UserNotFound => NotFound("User nicht gefunden."), + AdminResetPasswordFailureReason.EmployeeEmailMissing => BadRequest("Für den Einladungs-Modus braucht der Mitarbeiter eine hinterlegte E-Mail-Adresse."), + AdminResetPasswordFailureReason.InitialPasswordRequired => BadRequest("InitialPassword ist im Direct-Modus erforderlich."), + _ => BadRequest() + }; + } + + if (mode == UserCreationMode.Invite && result.Email is not null && result.RawInvitePin is not null) + { + await _dispatcher.DispatchAsync( + new UserInvitedEvent(result.Email, result.RawInvitePin, (int)pinValidity!.Value.TotalDays), + cancellationToken); + } + + return NoContent(); + } + + [HttpPut("{id:guid}")] + [RequirePermission(ModuleType.UserManagement, PermissionAction.Edit)] + public async Task Update(Guid id, UpdateUserRequest request, CancellationToken cancellationToken) + { + var result = await _userService.UpdateAsync(id, request.RoleId, request.IsActive, cancellationToken); + + if (!result.Success) + { + return result.FailureReason switch + { + UpdateUserFailureReason.UserNotFound => NotFound("User nicht gefunden."), + UpdateUserFailureReason.RoleNotFound => NotFound("Rolle nicht gefunden."), + _ => BadRequest() + }; + } + + return NoContent(); + } + + private static (string? Error, UserCreationMode Mode, TimeSpan? PinValidity) ParseModeAndPinValidity( + string modeRaw, int? pinValidityDays, string? initialPassword) + { + if (!Enum.TryParse(modeRaw, ignoreCase: true, out var mode)) + { + return ("Mode muss 'Invite' oder 'Direct' sein.", default, null); + } + + TimeSpan? pinValidity = null; + if (mode == UserCreationMode.Invite) + { + var days = pinValidityDays ?? DefaultPinValidityDays; + if (days < MinPinValidityDays || days > MaxPinValidityDays) + { + return ($"PinValidityDays muss zwischen {MinPinValidityDays} und {MaxPinValidityDays} liegen.", mode, null); + } + + pinValidity = TimeSpan.FromDays(days); + } + + if (mode == UserCreationMode.Direct + && (initialPassword is null || initialPassword.Length < MinInitialPasswordLength)) + { + return ($"InitialPassword ist im Direct-Modus erforderlich und muss mindestens {MinInitialPasswordLength} Zeichen lang sein.", mode, null); + } + + return (null, mode, pinValidity); + } + + private static UserResponse ToResponse(UserSummary summary) + => new(summary.Id, summary.Username, summary.EmployeeId, summary.RoleName, summary.IsActive, summary.MustChangePassword); +} diff --git a/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.csproj b/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.csproj new file mode 100644 index 0000000..9e2fc86 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + diff --git a/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.http b/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.http new file mode 100644 index 0000000..49c856d --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/OmsorgCore.Api.http @@ -0,0 +1,6 @@ +@OmsorgCore.Api_HostAddress = http://localhost:5245 + +GET {{OmsorgCore.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/omsorgCore/src/OmsorgCore.Api/Program.cs b/omsorgCore/src/OmsorgCore.Api/Program.cs new file mode 100644 index 0000000..9cbfcf0 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Program.cs @@ -0,0 +1,161 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using OmsorgCore.Api.Security; +using OmsorgCore.Application; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Email; +using OmsorgCore.Engine; +using OmsorgCore.Infrastructure; +using OmsorgCore.Infrastructure.Persistence; +using OmsorgCore.Infrastructure.Security; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Name = "Authorization", + Type = SecuritySchemeType.Http, + Scheme = "Bearer", + BearerFormat = "JWT", + In = ParameterLocation.Header + }); + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); +}); + +// 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.AddApplication(); +builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddEngine(); +builder.Services.AddEmail(builder.Configuration); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions(); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = jwtOptions.Issuer, + ValidAudience = jwtOptions.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)) + }; + + // Killswitch: ein JWT ist an sich zustandslos gültig bis zum Ablauf. Damit ein erzwungener + // Logout (SessionAdminService) SOFORT wirkt und nicht erst nach bis zu 60 Minuten, wird bei + // jedem Request der "sst"-Claim gegen den aktuellen User.SecurityStamp in der DB geprüft. + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + var userIdClaim = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Sub) + ?? context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + var stampClaim = context.Principal?.FindFirstValue("sst"); + + if (!Guid.TryParse(userIdClaim, out var userId) || string.IsNullOrEmpty(stampClaim)) + { + context.Fail("Token ohne gültige Benutzer-/Security-Stamp-Claims."); + return; + } + + var userRepository = context.HttpContext.RequestServices.GetRequiredService(); + var user = await userRepository.GetByIdAsync(userId); + + if (user is null || !user.IsActive || user.SecurityStamp.ToString() != stampClaim) + { + context.Fail("Session wurde widerrufen."); + return; + } + + // Erzwingt einen Passwortwechsel serverseitig (nicht nur im UI versteckt) - solange + // MustChangePassword gesetzt ist (Admin hat ein initiales Passwort direkt vergeben), + // sind nur noch change-password/logout/refresh/me erlaubt. Frischer DB-Wert statt + // Claim, damit ein erfolgreicher Wechsel sofort wirkt und nicht erst nach Token-Ablauf. + if (user.MustChangePassword) + { + var path = context.HttpContext.Request.Path; + var isAllowlisted = path.StartsWithSegments("/api/auth/change-password") + || path.StartsWithSegments("/api/auth/logout") + || path.StartsWithSegments("/api/auth/refresh") + || path.StartsWithSegments("/api/auth/me"); + + if (!isAllowlisted) + { + context.Fail("Passwort muss vor weiteren Aktionen geändert werden."); + } + } + } + }; + }); + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +// Wendet beim Start ausstehende Migrationen an - in jeder Umgebung, nicht nur Development, +// damit ein Deploy/Update nie mit einem Schema laufen kann, das hinter dem Code zurückliegt. +using (var migrationScope = app.Services.CreateScope()) +{ + var db = migrationScope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); + + try + { + using var seedScope = app.Services.CreateScope(); + var db = seedScope.ServiceProvider.GetRequiredService(); + var passwordHasher = seedScope.ServiceProvider.GetRequiredService(); + var seedOptions = seedScope.ServiceProvider.GetRequiredService>().Value; + await DbSeeder.SeedDefaultAdminAsync(db, passwordHasher, seedOptions); + } + catch (Exception ex) + { + app.Logger.LogWarning(ex, "Seed des Standard-Admin-Users fehlgeschlagen (z.B. Datenbank nicht erreichbar)."); + } +} + +app.UseHttpsRedirection(); + +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); diff --git a/omsorgCore/src/OmsorgCore.Api/Properties/launchSettings.json b/omsorgCore/src/OmsorgCore.Api/Properties/launchSettings.json new file mode 100644 index 0000000..605cdf2 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:35930", + "sslPort": 44310 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5245", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7268;http://localhost:5245", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Api/Security/CurrentUserService.cs b/omsorgCore/src/OmsorgCore.Api/Security/CurrentUserService.cs new file mode 100644 index 0000000..a796a35 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Security/CurrentUserService.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Api.Security; + +public class CurrentUserService : ICurrentUserService +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public CurrentUserService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User.Identity?.IsAuthenticated ?? false; + + public Guid? UserId + { + get + { + var value = _httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? _httpContextAccessor.HttpContext?.User.FindFirstValue("sub"); + + return Guid.TryParse(value, out var id) ? id : null; + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs b/omsorgCore/src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs new file mode 100644 index 0000000..d00c6cc --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/Security/RequirePermissionAttribute.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Mvc.Filters; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Enums; + +namespace OmsorgCore.Api.Security; + +/// +/// Erzwingt die Rechteprüfung auf Endpunkt-Ebene serverseitig, zusätzlich zur Prüfung auf +/// Datenebene in den Application-Services (REQUIREMENTS.md NFR-9). +/// Verwendung: [RequirePermission(ModuleType.Employees, PermissionAction.Create)] +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public class RequirePermissionAttribute : Attribute, IAsyncAuthorizationFilter +{ + private readonly ModuleType _module; + private readonly PermissionAction _action; + + public RequirePermissionAttribute(ModuleType module, PermissionAction action) + { + _module = module; + _action = action; + } + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + var currentUser = context.HttpContext.RequestServices.GetRequiredService(); + if (!currentUser.IsAuthenticated || currentUser.UserId is null) + { + context.Result = new Microsoft.AspNetCore.Mvc.UnauthorizedResult(); + return; + } + + var permissionService = context.HttpContext.RequestServices.GetRequiredService(); + var allowed = await permissionService.HasPermissionAsync(currentUser.UserId.Value, _module, _action); + + if (!allowed) + { + context.Result = new Microsoft.AspNetCore.Mvc.ForbidResult(); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Api/appsettings.json b/omsorgCore/src/OmsorgCore.Api/appsettings.json new file mode 100644 index 0000000..de720a9 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Api/appsettings.json @@ -0,0 +1,57 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "Jwt": { + "Issuer": "OmsorgCore", + "Audience": "OmsorgClients", + "ExpiryMinutes": 60 + }, + "RefreshToken": { + "ExpiryDays": 60 + }, + "Auth": { + "MaxLoginFailures": 5, + "LoginLockoutMinutes": 10 + }, + "Email": { + "PinExpiryMinutes": 5, + "MaxAttempts": 3, + "ResetTokenExpiryMinutes": 10, + "RequestCooldownSeconds": 60, + "Provider": "Console", + "PasswordResetAccount": "PasswordReset", + "UserInviteAccount": "UserInvite", + "Smtp": { + "Host": "", + "Port": 587, + "EnableSsl": true + }, + "Accounts": { + "PasswordReset": { + "Username": "", + "Password": "", + "SenderAddress": "", + "SenderDisplayName": "OMSORG" + }, + "UserInvite": { + "Username": "", + "Password": "", + "SenderAddress": "", + "SenderDisplayName": "OMSORG" + } + }, + "PasswordResetTemplate": { + "Subject": "Ihr OMSORG Passwort-Reset-Code", + "BodyTemplate": "Ihr Code lautet: $RESET_PIN$\nDer Code ist $RESET_PIN_EXPIRY_MINUTES$ Minuten gültig." + }, + "UserInviteTemplate": { + "Subject": "Ihr OMSORG-Zugang", + "BodyTemplate": "Für Sie wurde ein OMSORG-Konto angelegt.\nIhr Einladungscode lautet: $INVITE_PIN$\nDer Code ist $INVITE_PIN_EXPIRY_DAYS$ Tage gültig. Damit legen Sie beim ersten Login Ihr eigenes Passwort fest." + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/EmailMessage.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/EmailMessage.cs new file mode 100644 index 0000000..e861454 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/EmailMessage.cs @@ -0,0 +1,8 @@ +namespace OmsorgCore.Application.Abstractions; + +/// +/// wählt einen der konfigurierten Mail-Accounts (Username/Passwort/ +/// Absenderadresse) aus, z. B. den dedizierten Passwort-Reset-Account - der SMTP-Server selbst ist +/// gemeinsam konfiguriert, nur die Accounts unterscheiden sich. null = Default-Account. +/// +public record EmailMessage(string ToAddress, string Subject, string PlainTextBody, string? FromAccountKey = null); diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/ICurrentUserService.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/ICurrentUserService.cs new file mode 100644 index 0000000..727b611 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/ICurrentUserService.cs @@ -0,0 +1,12 @@ +namespace OmsorgCore.Application.Abstractions; + +/// +/// Liest die Identität des aktuell angemeldeten Benutzers aus dem Request-Kontext. +/// Implementierung lebt in der Api-Schicht (dort ist HttpContext bekannt), Application/Engine +/// kennen nur dieses Interface. +/// +public interface ICurrentUserService +{ + Guid? UserId { get; } + bool IsAuthenticated { get; } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmailSender.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmailSender.cs new file mode 100644 index 0000000..3bef9a8 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmailSender.cs @@ -0,0 +1,6 @@ +namespace OmsorgCore.Application.Abstractions; + +public interface IEmailSender +{ + Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeRepository.cs new file mode 100644 index 0000000..cb04dd4 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IEmployeeRepository.cs @@ -0,0 +1,19 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Abstractions; + +public interface IEmployeeRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + string? search, + string? status, + string? employmentType, + int page, + int pageSize, + CancellationToken cancellationToken = default); + Task AddAsync(Employee employee, CancellationToken cancellationToken = default); + Task UpdateAsync(Employee employee, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IJwtTokenGenerator.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IJwtTokenGenerator.cs new file mode 100644 index 0000000..41a0aff --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IJwtTokenGenerator.cs @@ -0,0 +1,8 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Abstractions; + +public interface IJwtTokenGenerator +{ + (string Token, DateTime ExpiresAt) GenerateToken(User user); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginAttemptRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginAttemptRepository.cs new file mode 100644 index 0000000..5e70cce --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginAttemptRepository.cs @@ -0,0 +1,16 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Abstractions; + +public interface ILoginAttemptRepository +{ + /// Anzahl fehlgeschlagener Versuche dieser IP seit . + Task CountRecentFailuresAsync(string ipAddress, DateTime since, CancellationToken cancellationToken = default); + + Task AddAsync(LoginAttempt attempt, CancellationToken cancellationToken = default); + + /// Löscht alle bisherigen Fehlversuche dieser IP - nach einem erfolgreichen Login. + Task ClearFailuresAsync(string ipAddress, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginLockoutPolicy.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginLockoutPolicy.cs new file mode 100644 index 0000000..9c4fd4c --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/ILoginLockoutPolicy.cs @@ -0,0 +1,8 @@ +namespace OmsorgCore.Application.Abstractions; + +/// Konfigurierte Schwellwerte für die IP-basierte Login-Sperre (siehe AuthService.LoginAsync). +public interface ILoginLockoutPolicy +{ + int MaxFailures { get; } + TimeSpan LockoutWindow { get; } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordHasher.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordHasher.cs new file mode 100644 index 0000000..ca18438 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordHasher.cs @@ -0,0 +1,7 @@ +namespace OmsorgCore.Application.Abstractions; + +public interface IPasswordHasher +{ + string Hash(string plainPassword); + bool Verify(string plainPassword, string hash); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeGenerator.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeGenerator.cs new file mode 100644 index 0000000..bf7813e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeGenerator.cs @@ -0,0 +1,23 @@ +namespace OmsorgCore.Application.Abstractions; + +public interface IPasswordResetCodeGenerator +{ + /// Mindestabstand zwischen zwei Code-Anfragen desselben Users (minimaler Spam-Schutz). + TimeSpan RequestCooldown { get; } + + /// Maximale Anzahl Fehlversuche bei der PIN-Eingabe, bevor der Code invalidiert wird. + int MaxAttempts { get; } + + /// Erzeugt einen neuen 6-stelligen PIN, dessen Hash zur Speicherung und sein Ablaufdatum. + (string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin(); + + /// Wie , aber mit explizit vorgegebener Gültigkeitsdauer statt der konfigurierten (z. B. für Account-Einladungen mit mehrtägiger Gültigkeit). + (string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin(TimeSpan validity); + + /// Erzeugt einen neuen Reset-Token, dessen Hash zur Speicherung und sein Ablaufdatum. + (string RawResetToken, string ResetTokenHash, DateTime ExpiresAt) GenerateResetToken(); + + string HashPin(string rawPin); + + string HashResetToken(string rawResetToken); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeRepository.cs new file mode 100644 index 0000000..9b4541e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetCodeRepository.cs @@ -0,0 +1,21 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Abstractions; + +public interface IPasswordResetCodeRepository +{ + Task AddAsync(PasswordResetCode code, CancellationToken cancellationToken = default); + + /// Aktivster (nicht widerrufener, nicht abgelaufener, noch nicht konsumierter, Versuche unter maxAttempts) Code eines Users, falls vorhanden. + Task GetActiveByUserIdAsync(Guid userId, int maxAttempts, CancellationToken cancellationToken = default); + + /// Der jüngste Code eines Users unabhängig vom Status - für den Anfrage-Cooldown. + Task GetLatestByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + Task GetByResetTokenHashAsync(string resetTokenHash, CancellationToken cancellationToken = default); + + /// Entwertet alle noch aktiven Codes eines Users (z. B. bei einer neuen Anfrage - immer nur ein gültiger Code). + Task InvalidateActiveForUserAsync(Guid userId, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetEmailComposer.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetEmailComposer.cs new file mode 100644 index 0000000..8517957 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPasswordResetEmailComposer.cs @@ -0,0 +1,11 @@ +namespace OmsorgCore.Application.Abstractions; + +/// +/// Baut die fertige Passwort-Reset-Mail (Betreff+Text) aus dem PIN. Implementierung liegt in +/// OmsorgCore.Email (kennt die dortige Template-Konfiguration) - Engine kennt nur dieses Interface, +/// damit die Abhängigkeitsrichtung (Engine -> Application, nicht -> Email) gewahrt bleibt. +/// +public interface IPasswordResetEmailComposer +{ + EmailMessage Compose(string toEmail, string rawPin); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IPermissionService.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPermissionService.cs new file mode 100644 index 0000000..d1146ca --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IPermissionService.cs @@ -0,0 +1,17 @@ +using OmsorgCore.Application.Models; +using OmsorgCore.Domain.Enums; + +namespace OmsorgCore.Application.Abstractions; + +/// +/// Zentrale Rechteprüfung: Rollen-Default kombiniert mit individuellen Overrides des Benutzers. +/// Wird sowohl vom API-Authorization-Attribut als auch von Business-Services genutzt, damit +/// Rechte auf Daten- UND Funktionsebene geprüft werden (REQUIREMENTS.md NFR-9). +/// +public interface IPermissionService +{ + Task HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default); + + /// Alle tatsächlich gewährten Modul/Aktion-Kombinationen dieses Users (Rollen-Default + Overrides aufgelöst). + Task> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenGenerator.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenGenerator.cs new file mode 100644 index 0000000..092192b --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenGenerator.cs @@ -0,0 +1,10 @@ +namespace OmsorgCore.Application.Abstractions; + +public interface IRefreshTokenGenerator +{ + /// Erzeugt einen neuen zufälligen Rohtoken, dessen Hash zur Speicherung und sein Ablaufdatum. + (string RawToken, string TokenHash, DateTime ExpiresAt) GenerateToken(); + + /// Hasht einen vom Client vorgelegten Rohtoken zum Nachschlagen in der DB. + string HashToken(string rawToken); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenRepository.cs new file mode 100644 index 0000000..736869c --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRefreshTokenRepository.cs @@ -0,0 +1,20 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Abstractions; + +public interface IRefreshTokenRepository +{ + Task AddAsync(RefreshToken token, CancellationToken cancellationToken = default); + Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default); + + /// Für die Admin-Sessions-Ansicht - dort ist nur die Session-Id bekannt, nicht der Rohtoken. + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// Alle nicht widerrufenen, nicht abgelaufenen Refresh-Tokens (inkl. User) für die Sessions-Liste. + Task> GetAllActiveAsync(CancellationToken cancellationToken = default); + + /// Alle aktiven Refresh-Tokens eines bestimmten Users (für die Ein-Session-Regel beim Login). + Task> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs new file mode 100644 index 0000000..024b8b1 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IRoleRepository.cs @@ -0,0 +1,12 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Abstractions; + +public interface IRoleRepository +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task ExistsByNameAsync(string name, CancellationToken cancellationToken = default); + Task AddAsync(Role role, CancellationToken cancellationToken = default); + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserInviteEmailComposer.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserInviteEmailComposer.cs new file mode 100644 index 0000000..cf224c5 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserInviteEmailComposer.cs @@ -0,0 +1,10 @@ +namespace OmsorgCore.Application.Abstractions; + +/// +/// Baut die fertige Einladungsmail (Betreff+Text) aus dem Invite-PIN. Implementierung liegt in +/// OmsorgCore.Email (analog IPasswordResetEmailComposer) - Engine kennt nur dieses Interface. +/// +public interface IUserInviteEmailComposer +{ + EmailMessage Compose(string toEmail, string rawPin, int validityDays); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserRepository.cs b/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserRepository.cs new file mode 100644 index 0000000..1502690 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Abstractions/IUserRepository.cs @@ -0,0 +1,27 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Abstractions; + +public interface IUserRepository +{ + Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default); + + /// Inklusive Role+RolePermissions+PermissionOverrides (für die Rechteauflösung) und Employee (für /api/auth/me). + Task GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default); + + /// Schlanker Lookup ohne Includes - für den SecurityStamp-Check bei jedem Request. + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + /// Inklusive Employee (für die E-Mail-Adresse) und Role (für den Rollennamen in Ergebnissen) - für Admin-Aktionen auf einem bestehenden User. + Task GetByIdWithEmployeeAsync(Guid id, CancellationToken cancellationToken = default); + + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default); + + Task ExistsByUsernameAsync(string username, CancellationToken cancellationToken = default); + + Task AddAsync(User user, CancellationToken cancellationToken = default); + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs new file mode 100644 index 0000000..a41b5f1 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/DependencyInjection.cs @@ -0,0 +1,21 @@ +using Microsoft.Extensions.DependencyInjection; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Application.Services; + +namespace OmsorgCore.Application; + +public static class DependencyInjection +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Models/PermissionGrant.cs b/omsorgCore/src/OmsorgCore.Application/Models/PermissionGrant.cs new file mode 100644 index 0000000..7a15a99 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Models/PermissionGrant.cs @@ -0,0 +1,6 @@ +using OmsorgCore.Domain.Enums; + +namespace OmsorgCore.Application.Models; + +/// Eine für einen User tatsächlich gewährte Modul/Aktion-Kombination (Ergebnis der Rechte-Auflösung). +public record PermissionGrant(ModuleType Module, PermissionAction Action); diff --git a/omsorgCore/src/OmsorgCore.Application/Models/UserProfile.cs b/omsorgCore/src/OmsorgCore.Application/Models/UserProfile.cs new file mode 100644 index 0000000..9406cb1 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Models/UserProfile.cs @@ -0,0 +1,19 @@ +namespace OmsorgCore.Application.Models; + +/// +/// Profil des eingeloggten Users samt aufgelöster Rechte, für den `/api/auth/me`-Endpunkt. Trägt +/// zusätzlich die verknüpften Employee-Basisdaten (falls vorhanden) - JWT und Login-Response tragen +/// nur Username/Rolle, Clients brauchen für "angemeldet als ..."-Anzeigen aber Name/E-Mail/Avatar, +/// ohne dafür einen separaten Employee-Request zu brauchen (siehe omsorgWeb/mitarbeiter-app). +/// +public record UserProfile( + Guid UserId, + string Username, + string RoleName, + IReadOnlyList Permissions, + Guid? EmployeeId, + string? FirstName, + string? LastName, + string? Email, + string? PhoneNumber, + string? AvatarFileName); diff --git a/omsorgCore/src/OmsorgCore.Application/OmsorgCore.Application.csproj b/omsorgCore/src/OmsorgCore.Application/OmsorgCore.Application.csproj new file mode 100644 index 0000000..e15c016 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/OmsorgCore.Application.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/omsorgCore/src/OmsorgCore.Application/Services/AdminResetPasswordResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/AdminResetPasswordResult.cs new file mode 100644 index 0000000..102fe30 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/AdminResetPasswordResult.cs @@ -0,0 +1,31 @@ +namespace OmsorgCore.Application.Services; + +public enum AdminResetPasswordFailureReason +{ + UserNotFound, + EmployeeEmailMissing, + InitialPasswordRequired +} + +public class AdminResetPasswordResult +{ + public bool Success { get; init; } + public AdminResetPasswordFailureReason? FailureReason { get; init; } + + /// Nur im Invite-Modus gesetzt - für den Versand der Einladungsmail. + public string? Email { get; init; } + public string? RawInvitePin { get; init; } + + public static AdminResetPasswordResult Fail(AdminResetPasswordFailureReason reason) => new() + { + Success = false, + FailureReason = reason + }; + + public static AdminResetPasswordResult Ok(string? email = null, string? rawInvitePin = null) => new() + { + Success = true, + Email = email, + RawInvitePin = rawInvitePin + }; +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/AuthResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/AuthResult.cs new file mode 100644 index 0000000..2c66eb5 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/AuthResult.cs @@ -0,0 +1,24 @@ +namespace OmsorgCore.Application.Services; + +public class AuthResult +{ + public bool Success { get; init; } + public string? Token { get; init; } + public string? RefreshToken { get; init; } + public DateTime? ExpiresAt { get; init; } + public bool MustChangePassword { get; init; } + public bool IsLockedOut { get; init; } + + public static AuthResult Fail() => new() { Success = false }; + + public static AuthResult LockedOut() => new() { Success = false, IsLockedOut = true }; + + public static AuthResult Ok(string token, string refreshToken, DateTime expiresAt, bool mustChangePassword) => new() + { + Success = true, + Token = token, + RefreshToken = refreshToken, + ExpiresAt = expiresAt, + MustChangePassword = mustChangePassword + }; +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/AuthService.cs b/omsorgCore/src/OmsorgCore.Application/Services/AuthService.cs new file mode 100644 index 0000000..d2d6db3 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/AuthService.cs @@ -0,0 +1,155 @@ +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Application.Models; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Services; + +public class AuthService : IAuthService +{ + private readonly IUserRepository _userRepository; + private readonly IRefreshTokenRepository _refreshTokenRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IJwtTokenGenerator _tokenGenerator; + private readonly IRefreshTokenGenerator _refreshTokenGenerator; + private readonly IPermissionService _permissionService; + private readonly ILoginAttemptRepository _loginAttemptRepository; + private readonly ILoginLockoutPolicy _loginLockoutPolicy; + + public AuthService( + IUserRepository userRepository, + IRefreshTokenRepository refreshTokenRepository, + IPasswordHasher passwordHasher, + IJwtTokenGenerator tokenGenerator, + IRefreshTokenGenerator refreshTokenGenerator, + IPermissionService permissionService, + ILoginAttemptRepository loginAttemptRepository, + ILoginLockoutPolicy loginLockoutPolicy) + { + _userRepository = userRepository; + _refreshTokenRepository = refreshTokenRepository; + _passwordHasher = passwordHasher; + _tokenGenerator = tokenGenerator; + _refreshTokenGenerator = refreshTokenGenerator; + _permissionService = permissionService; + _loginAttemptRepository = loginAttemptRepository; + _loginLockoutPolicy = loginLockoutPolicy; + } + + public async Task LoginAsync(string username, string password, string ipAddress, CancellationToken cancellationToken = default) + { + var lockoutSince = DateTime.UtcNow - _loginLockoutPolicy.LockoutWindow; + var recentFailures = await _loginAttemptRepository.CountRecentFailuresAsync(ipAddress, lockoutSince, cancellationToken); + if (recentFailures >= _loginLockoutPolicy.MaxFailures) + { + return AuthResult.LockedOut(); + } + + var user = await _userRepository.GetByUsernameAsync(username, cancellationToken); + if (user is null || !user.IsActive || !_passwordHasher.Verify(password, user.PasswordHash)) + { + await _loginAttemptRepository.AddAsync(new LoginAttempt + { + IpAddress = ipAddress, + Username = username, + Succeeded = false + }, cancellationToken); + await _loginAttemptRepository.SaveChangesAsync(cancellationToken); + + return AuthResult.Fail(); + } + + await _loginAttemptRepository.ClearFailuresAsync(ipAddress, cancellationToken); + await _loginAttemptRepository.SaveChangesAsync(cancellationToken); + + await EndOtherSessionsAsync(user, cancellationToken); + + return await IssueTokenPairAsync(user, cancellationToken); + } + + // Erzwingt "ein User = eine aktive Session": widerruft alle bisherigen Refresh-Tokens dieses + // Users und würfelt den SecurityStamp neu, damit auch bereits ausgestellte Access-Tokens + // anderer Sessions sofort ungültig werden (derselbe Killswitch-Mechanismus wie SessionAdminService). + private async Task EndOtherSessionsAsync(User user, CancellationToken cancellationToken) + { + var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(user.Id, cancellationToken); + foreach (var session in activeSessions) + { + session.RevokedAt = DateTime.UtcNow; + } + + user.SecurityStamp = Guid.NewGuid(); + } + + public async Task RefreshAsync(string refreshToken, CancellationToken cancellationToken = default) + { + var tokenHash = _refreshTokenGenerator.HashToken(refreshToken); + var existing = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken); + if (existing is null || !existing.IsActive) + { + return AuthResult.Fail(); + } + + existing.RevokedAt = DateTime.UtcNow; + + return await IssueTokenPairAsync(existing.User, cancellationToken, replaces: existing); + } + + public async Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default) + { + var tokenHash = _refreshTokenGenerator.HashToken(refreshToken); + var existing = await _refreshTokenRepository.GetByTokenHashAsync(tokenHash, cancellationToken); + if (existing is null || existing.RevokedAt is not null) + { + return; + } + + existing.RevokedAt = DateTime.UtcNow; + await _refreshTokenRepository.SaveChangesAsync(cancellationToken); + } + + public async Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken); + if (user is null || !user.IsActive) + { + return null; + } + + var permissions = await _permissionService.GetGrantedPermissionsAsync(userId, cancellationToken); + return new UserProfile( + user.Id, + user.Username, + user.Role.Name, + permissions, + user.EmployeeId, + user.Employee?.FirstName, + user.Employee?.LastName, + user.Employee?.Email, + user.Employee?.PhoneNumber, + user.Employee?.AvatarFileName); + } + + private async Task IssueTokenPairAsync(User user, CancellationToken cancellationToken, RefreshToken? replaces = null) + { + var (accessToken, accessTokenExpiresAt) = _tokenGenerator.GenerateToken(user); + var (rawRefreshToken, refreshTokenHash, refreshTokenExpiresAt) = _refreshTokenGenerator.GenerateToken(); + + var newToken = new RefreshToken + { + UserId = user.Id, + User = user, + TokenHash = refreshTokenHash, + ExpiresAt = refreshTokenExpiresAt + }; + await _refreshTokenRepository.AddAsync(newToken, cancellationToken); + + if (replaces is not null) + { + replaces.ReplacedByTokenId = newToken.Id; + } + + await _refreshTokenRepository.SaveChangesAsync(cancellationToken); + + return AuthResult.Ok(accessToken, rawRefreshToken, accessTokenExpiresAt, user.MustChangePassword); + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/CreateRoleResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/CreateRoleResult.cs new file mode 100644 index 0000000..9b1f4d4 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/CreateRoleResult.cs @@ -0,0 +1,28 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Services; + +public enum CreateRoleFailureReason +{ + NameRequired, + NameTaken +} + +public class CreateRoleResult +{ + public bool Success { get; init; } + public CreateRoleFailureReason? FailureReason { get; init; } + public Role? Role { get; init; } + + public static CreateRoleResult Fail(CreateRoleFailureReason reason) => new() + { + Success = false, + FailureReason = reason + }; + + public static CreateRoleResult Ok(Role role) => new() + { + Success = true, + Role = role + }; +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/CreateUserResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/CreateUserResult.cs new file mode 100644 index 0000000..b718eda --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/CreateUserResult.cs @@ -0,0 +1,38 @@ +namespace OmsorgCore.Application.Services; + +public enum CreateUserFailureReason +{ + EmployeeNotFound, + EmployeeAlreadyHasAccount, + EmployeeEmailMissing, + UsernameTaken, + RoleNotFound, + InitialPasswordRequired +} + +public class CreateUserResult +{ + public bool Success { get; init; } + public CreateUserFailureReason? FailureReason { get; init; } + public Guid? UserId { get; init; } + public string? RoleName { get; init; } + + /// Nur im Invite-Modus gesetzt - für den Versand der Einladungsmail. + public string? Email { get; init; } + public string? RawInvitePin { get; init; } + + public static CreateUserResult Fail(CreateUserFailureReason reason) => new() + { + Success = false, + FailureReason = reason + }; + + public static CreateUserResult Ok(Guid userId, string roleName, string? email = null, string? rawInvitePin = null) => new() + { + Success = true, + UserId = userId, + RoleName = roleName, + Email = email, + RawInvitePin = rawInvitePin + }; +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/EmployeeService.cs b/omsorgCore/src/OmsorgCore.Application/Services/EmployeeService.cs new file mode 100644 index 0000000..9c6f20f --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/EmployeeService.cs @@ -0,0 +1,69 @@ +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Services; + +public class EmployeeService : IEmployeeService +{ + private readonly IEmployeeRepository _employeeRepository; + + public EmployeeService(IEmployeeRepository employeeRepository) + { + _employeeRepository = employeeRepository; + } + + public Task> GetAllAsync(CancellationToken cancellationToken = default) + => _employeeRepository.GetAllAsync(cancellationToken); + + public Task<(IReadOnlyList 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); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _employeeRepository.GetByIdAsync(id, cancellationToken); + + public async Task CreateAsync(Employee employee, CancellationToken cancellationToken = default) + { + await _employeeRepository.AddAsync(employee, cancellationToken); + await _employeeRepository.SaveChangesAsync(cancellationToken); + return employee; + } + + public async Task UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default) + { + var employee = await _employeeRepository.GetByIdAsync(id, cancellationToken); + if (employee is null) + { + return null; + } + + employee.FirstName = updates.FirstName; + employee.LastName = updates.LastName; + employee.DateOfBirth = updates.DateOfBirth; + employee.Street = updates.Street; + employee.PostalCode = updates.PostalCode; + employee.City = updates.City; + employee.Country = updates.Country; + employee.PhoneNumber = updates.PhoneNumber; + employee.Email = updates.Email; + employee.EntryDate = updates.EntryDate; + employee.ExitDate = updates.ExitDate; + employee.Status = updates.Status; + employee.EmergencyContactName = updates.EmergencyContactName; + employee.EmergencyContactPhone = updates.EmergencyContactPhone; + employee.EmergencyContactRelation = updates.EmergencyContactRelation; + employee.EmploymentType = updates.EmploymentType; + employee.Qualification = updates.Qualification; + employee.AvatarFileName = updates.AvatarFileName; + employee.UpdatedAt = DateTime.UtcNow; + + await _employeeRepository.UpdateAsync(employee, cancellationToken); + await _employeeRepository.SaveChangesAsync(cancellationToken); + return employee; + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IAuthService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IAuthService.cs new file mode 100644 index 0000000..cea4b4d --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/IAuthService.cs @@ -0,0 +1,17 @@ +using OmsorgCore.Application.Models; + +namespace OmsorgCore.Application.Services; + +public interface IAuthService +{ + Task LoginAsync(string username, string password, string ipAddress, CancellationToken cancellationToken = default); + + /// Löst den vorgelegten Refresh-Token ein: widerruft ihn und stellt ein neues Token-Paar aus (Rotation). + Task RefreshAsync(string refreshToken, CancellationToken cancellationToken = default); + + /// Widerruft einen Refresh-Token (Logout). Idempotent, kein Fehler bei unbekanntem/bereits widerrufenem Token. + Task RevokeAsync(string refreshToken, CancellationToken cancellationToken = default); + + /// Profil (Username, Rolle, aufgelöste Rechte) des eingeloggten Users, für `/api/auth/me`. + Task GetProfileAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeService.cs new file mode 100644 index 0000000..84fc905 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/IEmployeeService.cs @@ -0,0 +1,18 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Services; + +public interface IEmployeeService +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + string? search, + string? status, + string? employmentType, + int page, + int pageSize, + CancellationToken cancellationToken = default); + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task CreateAsync(Employee employee, CancellationToken cancellationToken = default); + Task UpdateAsync(Guid id, Employee updates, CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IPasswordResetService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IPasswordResetService.cs new file mode 100644 index 0000000..c71cfd8 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/IPasswordResetService.cs @@ -0,0 +1,17 @@ +namespace OmsorgCore.Application.Services; + +public interface IPasswordResetService +{ + Task RequestResetAsync(string username, CancellationToken cancellationToken = default); + + Task VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default); + + Task ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default); + + /// + /// Stellt einen PIN für eine Account-Einladung aus (admin-ausgelöst, kein Self-Service-Request des Users + /// selbst) - läuft durch dieselbe PasswordResetCode-Tabelle wie RequestResetAsync, aber ohne Cooldown-Prüfung + /// und mit einer explizit vorgegebenen, i. d. R. mehrtägigen Gültigkeit statt der kurzen Reset-PIN-Gültigkeit. + /// + Task IssueInviteCodeAsync(Guid userId, TimeSpan validity, CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs new file mode 100644 index 0000000..1e89678 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/IRoleService.cs @@ -0,0 +1,9 @@ +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Services; + +public interface IRoleService +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task CreateAsync(string name, CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/ISessionAdminService.cs b/omsorgCore/src/OmsorgCore.Application/Services/ISessionAdminService.cs new file mode 100644 index 0000000..a030929 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/ISessionAdminService.cs @@ -0,0 +1,12 @@ +namespace OmsorgCore.Application.Services; + +public interface ISessionAdminService +{ + Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default); + + /// Widerruft genau diese Session und erzwingt für ihren Nutzer sofort einen neuen Login. + Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default); + + /// Widerruft alle aktiven Sessions und erzwingt für ALLE Nutzer sofort einen neuen Login. + Task RevokeAllSessionsAsync(CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/IUserService.cs b/omsorgCore/src/OmsorgCore.Application/Services/IUserService.cs new file mode 100644 index 0000000..ed63a04 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/IUserService.cs @@ -0,0 +1,36 @@ +namespace OmsorgCore.Application.Services; + +public interface IUserService +{ + Task> GetAllAsync(CancellationToken cancellationToken = default); + + Task CreateForEmployeeAsync( + Guid employeeId, + string username, + Guid roleId, + UserCreationMode mode, + string? initialPassword, + TimeSpan? invitePinValidity, + CancellationToken cancellationToken = default); + + Task ChangeOwnPasswordAsync( + Guid userId, + string currentPassword, + string newPassword, + CancellationToken cancellationToken = default); + + /// Admin setzt das Passwort eines bestehenden Users zurück (Invite-PIN oder direktes Passwort) - spiegelt CreateForEmployeeAsync, aber ohne Neuanlage. + Task AdminResetPasswordAsync( + Guid userId, + UserCreationMode mode, + string? initialPassword, + TimeSpan? invitePinValidity, + CancellationToken cancellationToken = default); + + /// Rolle und/oder Aktiv-Status ändern. Deaktivieren beendet sofort alle Sessions (kein Hard-Delete). + Task UpdateAsync( + Guid userId, + Guid roleId, + bool isActive, + CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetRequestResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetRequestResult.cs new file mode 100644 index 0000000..2ac017f --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetRequestResult.cs @@ -0,0 +1,27 @@ +namespace OmsorgCore.Application.Services; + +public enum PasswordResetRequestStatus +{ + Sent, + CannotReset +} + +public class PasswordResetRequestResult +{ + public PasswordResetRequestStatus Status { get; init; } + public Guid? UserId { get; init; } + public string? Email { get; init; } + + /// Nur gesetzt, wenn tatsächlich ein neuer Code erzeugt wurde und dispatcht werden soll. + public string? RawPin { get; init; } + + public static PasswordResetRequestResult CannotReset() => new() { Status = PasswordResetRequestStatus.CannotReset }; + + public static PasswordResetRequestResult Sent(Guid userId, string email, string? rawPin) => new() + { + Status = PasswordResetRequestStatus.Sent, + UserId = userId, + Email = email, + RawPin = rawPin + }; +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetService.cs b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetService.cs new file mode 100644 index 0000000..fd50497 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetService.cs @@ -0,0 +1,139 @@ +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Services; + +public class PasswordResetService : IPasswordResetService +{ + private readonly IUserRepository _userRepository; + private readonly IPasswordResetCodeRepository _codeRepository; + private readonly IPasswordResetCodeGenerator _codeGenerator; + private readonly IPasswordHasher _passwordHasher; + private readonly IRefreshTokenRepository _refreshTokenRepository; + + public PasswordResetService( + IUserRepository userRepository, + IPasswordResetCodeRepository codeRepository, + IPasswordResetCodeGenerator codeGenerator, + IPasswordHasher passwordHasher, + IRefreshTokenRepository refreshTokenRepository) + { + _userRepository = userRepository; + _codeRepository = codeRepository; + _codeGenerator = codeGenerator; + _passwordHasher = passwordHasher; + _refreshTokenRepository = refreshTokenRepository; + } + + public async Task RequestResetAsync(string username, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByUsernameAsync(username, cancellationToken); + var email = user?.Employee?.Email; + if (user is null || !user.IsActive || string.IsNullOrWhiteSpace(email)) + { + return PasswordResetRequestResult.CannotReset(); + } + + var latest = await _codeRepository.GetLatestByUserIdAsync(user.Id, cancellationToken); + if (latest is not null && DateTime.UtcNow - latest.CreatedAt < _codeGenerator.RequestCooldown) + { + // Cooldown aktiv: kein neuer Code, kein erneuter Versand, aber nach außen unauffällig - + // der Client bekommt weiterhin "Sent" ohne dass tatsächlich etwas verschickt wird. + return PasswordResetRequestResult.Sent(user.Id, email, rawPin: null); + } + + await _codeRepository.InvalidateActiveForUserAsync(user.Id, cancellationToken); + + var (rawPin, pinHash, expiresAt) = _codeGenerator.GeneratePin(); + await _codeRepository.AddAsync(new PasswordResetCode + { + UserId = user.Id, + User = user, + CodeHash = pinHash, + ExpiresAt = expiresAt + }, cancellationToken); + await _codeRepository.SaveChangesAsync(cancellationToken); + + return PasswordResetRequestResult.Sent(user.Id, email, rawPin); + } + + public async Task VerifyCodeAsync(string username, string pin, CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByUsernameAsync(username, cancellationToken); + if (user is null) + { + return PasswordResetVerifyResult.Fail("invalid_or_expired"); + } + + var code = await _codeRepository.GetActiveByUserIdAsync(user.Id, _codeGenerator.MaxAttempts, cancellationToken); + if (code is null) + { + return PasswordResetVerifyResult.Fail("invalid_or_expired"); + } + + var pinHash = _codeGenerator.HashPin(pin); + if (code.CodeHash != pinHash) + { + code.AttemptCount++; + if (code.AttemptCount >= _codeGenerator.MaxAttempts) + { + code.InvalidatedAt = DateTime.UtcNow; + } + await _codeRepository.SaveChangesAsync(cancellationToken); + + return code.AttemptCount >= _codeGenerator.MaxAttempts + ? PasswordResetVerifyResult.Fail("too_many_attempts") + : PasswordResetVerifyResult.Fail("invalid_or_expired"); + } + + code.ConsumedAt = DateTime.UtcNow; + var (rawResetToken, resetTokenHash, resetExpiresAt) = _codeGenerator.GenerateResetToken(); + code.ResetTokenHash = resetTokenHash; + code.ResetTokenExpiresAt = resetExpiresAt; + await _codeRepository.SaveChangesAsync(cancellationToken); + + return PasswordResetVerifyResult.Ok(rawResetToken); + } + + public async Task IssueInviteCodeAsync(Guid userId, TimeSpan validity, CancellationToken cancellationToken = default) + { + await _codeRepository.InvalidateActiveForUserAsync(userId, cancellationToken); + + var (rawPin, pinHash, expiresAt) = _codeGenerator.GeneratePin(validity); + await _codeRepository.AddAsync(new PasswordResetCode + { + UserId = userId, + CodeHash = pinHash, + ExpiresAt = expiresAt + }, cancellationToken); + await _codeRepository.SaveChangesAsync(cancellationToken); + + return rawPin; + } + + public async Task ResetPasswordAsync(string resetToken, string newPassword, CancellationToken cancellationToken = default) + { + var resetTokenHash = _codeGenerator.HashResetToken(resetToken); + var code = await _codeRepository.GetByResetTokenHashAsync(resetTokenHash, cancellationToken); + if (code is null || !code.IsResetTokenActive) + { + return false; + } + + code.ResetTokenUsedAt = DateTime.UtcNow; + code.User.PasswordHash = _passwordHasher.Hash(newPassword); + + // Wie nach einem normalen Login: alle bestehenden Sessions beenden, SecurityStamp erneuern - + // ein Passwort-Reset darf alte, evtl. kompromittierte Sessions nicht überleben lassen. + var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(code.UserId, cancellationToken); + foreach (var session in activeSessions) + { + session.RevokedAt = DateTime.UtcNow; + } + code.User.SecurityStamp = Guid.NewGuid(); + + await _codeRepository.SaveChangesAsync(cancellationToken); + await _refreshTokenRepository.SaveChangesAsync(cancellationToken); + return true; + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetVerifyResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetVerifyResult.cs new file mode 100644 index 0000000..c49f9ec --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/PasswordResetVerifyResult.cs @@ -0,0 +1,14 @@ +namespace OmsorgCore.Application.Services; + +public class PasswordResetVerifyResult +{ + public bool Success { get; init; } + public string? ResetToken { get; init; } + + /// "invalid_or_expired" | "too_many_attempts" + public string? FailureReason { get; init; } + + public static PasswordResetVerifyResult Fail(string reason) => new() { Success = false, FailureReason = reason }; + + public static PasswordResetVerifyResult Ok(string resetToken) => new() { Success = true, ResetToken = resetToken }; +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/PermissionService.cs b/omsorgCore/src/OmsorgCore.Application/Services/PermissionService.cs new file mode 100644 index 0000000..89934a0 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/PermissionService.cs @@ -0,0 +1,67 @@ +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Application.Models; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Domain.Enums; + +namespace OmsorgCore.Application.Services; + +/// +/// Implementiert die Regel aus REQUIREMENTS.md Abschnitt 3/7 und Blueprint 6.5: +/// Rollen-Default gilt, ein individueller Override (Grant oder Revoke) gewinnt immer. +/// +public class PermissionService : IPermissionService +{ + private readonly IUserRepository _userRepository; + + public PermissionService(IUserRepository userRepository) + { + _userRepository = userRepository; + } + + public async Task HasPermissionAsync(Guid userId, ModuleType module, PermissionAction action, CancellationToken cancellationToken = default) + { + User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken); + if (user is null || !user.IsActive) + { + return false; + } + + return IsGranted(user, module, action); + } + + public async Task> GetGrantedPermissionsAsync(Guid userId, CancellationToken cancellationToken = default) + { + User? user = await _userRepository.GetByIdWithPermissionsAsync(userId, cancellationToken); + if (user is null || !user.IsActive) + { + return Array.Empty(); + } + + var grants = new List(); + foreach (ModuleType module in Enum.GetValues()) + { + foreach (PermissionAction action in Enum.GetValues()) + { + if (IsGranted(user, module, action)) + { + grants.Add(new PermissionGrant(module, action)); + } + } + } + + return grants; + } + + private static bool IsGranted(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 user.Role.RolePermissions.Any(rp => rp.Module == module && rp.Action == action); + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs b/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs new file mode 100644 index 0000000..30d4aec --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/RoleService.cs @@ -0,0 +1,36 @@ +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Services; + +public class RoleService : IRoleService +{ + private readonly IRoleRepository _roleRepository; + + public RoleService(IRoleRepository roleRepository) + { + _roleRepository = roleRepository; + } + + public Task> GetAllAsync(CancellationToken cancellationToken = default) + => _roleRepository.GetAllAsync(cancellationToken); + + public async Task CreateAsync(string name, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(name)) + { + return CreateRoleResult.Fail(CreateRoleFailureReason.NameRequired); + } + + if (await _roleRepository.ExistsByNameAsync(name, cancellationToken)) + { + return CreateRoleResult.Fail(CreateRoleFailureReason.NameTaken); + } + + var role = new Role { Name = name }; + await _roleRepository.AddAsync(role, cancellationToken); + await _roleRepository.SaveChangesAsync(cancellationToken); + + return CreateRoleResult.Ok(role); + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/SessionAdminService.cs b/omsorgCore/src/OmsorgCore.Application/Services/SessionAdminService.cs new file mode 100644 index 0000000..2f3a862 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/SessionAdminService.cs @@ -0,0 +1,54 @@ +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Application.Services; + +public class SessionAdminService : ISessionAdminService +{ + private readonly IRefreshTokenRepository _refreshTokenRepository; + private readonly IUserRepository _userRepository; + + public SessionAdminService(IRefreshTokenRepository refreshTokenRepository, IUserRepository userRepository) + { + _refreshTokenRepository = refreshTokenRepository; + _userRepository = userRepository; + } + + public async Task> GetActiveSessionsAsync(CancellationToken cancellationToken = default) + { + var tokens = await _refreshTokenRepository.GetAllActiveAsync(cancellationToken); + return tokens + .Select(t => new SessionInfo(t.Id, t.User.Username, t.CreatedAt, t.ExpiresAt)) + .ToList(); + } + + public async Task RevokeSessionAsync(Guid sessionId, CancellationToken cancellationToken = default) + { + var token = await _refreshTokenRepository.GetByIdAsync(sessionId, cancellationToken); + if (token is null || token.RevokedAt is not null) + { + return; + } + + token.RevokedAt = DateTime.UtcNow; + token.User.SecurityStamp = Guid.NewGuid(); + + await _refreshTokenRepository.SaveChangesAsync(cancellationToken); + } + + public async Task RevokeAllSessionsAsync(CancellationToken cancellationToken = default) + { + var activeTokens = await _refreshTokenRepository.GetAllActiveAsync(cancellationToken); + foreach (var token in activeTokens) + { + token.RevokedAt = DateTime.UtcNow; + } + + var users = await _userRepository.GetAllAsync(cancellationToken); + foreach (var user in users) + { + user.SecurityStamp = Guid.NewGuid(); + } + + await _userRepository.SaveChangesAsync(cancellationToken); + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/SessionInfo.cs b/omsorgCore/src/OmsorgCore.Application/Services/SessionInfo.cs new file mode 100644 index 0000000..08a7b82 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/SessionInfo.cs @@ -0,0 +1,3 @@ +namespace OmsorgCore.Application.Services; + +public record SessionInfo(Guid Id, string Username, DateTime CreatedAt, DateTime ExpiresAt); diff --git a/omsorgCore/src/OmsorgCore.Application/Services/UpdateUserResult.cs b/omsorgCore/src/OmsorgCore.Application/Services/UpdateUserResult.cs new file mode 100644 index 0000000..d15c9c9 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/UpdateUserResult.cs @@ -0,0 +1,21 @@ +namespace OmsorgCore.Application.Services; + +public enum UpdateUserFailureReason +{ + UserNotFound, + RoleNotFound +} + +public class UpdateUserResult +{ + public bool Success { get; init; } + public UpdateUserFailureReason? FailureReason { get; init; } + + public static UpdateUserResult Fail(UpdateUserFailureReason reason) => new() + { + Success = false, + FailureReason = reason + }; + + public static UpdateUserResult Ok() => new() { Success = true }; +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/UserCreationMode.cs b/omsorgCore/src/OmsorgCore.Application/Services/UserCreationMode.cs new file mode 100644 index 0000000..4f9c56b --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/UserCreationMode.cs @@ -0,0 +1,10 @@ +namespace OmsorgCore.Application.Services; + +public enum UserCreationMode +{ + /// Mitarbeiter bekommt eine Einladungsmail mit PIN und setzt sein Passwort selbst - der Admin kennt es nie. + Invite, + + /// Admin vergibt ein initiales Passwort direkt (z. B. ohne hinterlegte E-Mail) - MustChangePassword erzwingt einen Wechsel beim ersten Login. + Direct +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/UserService.cs b/omsorgCore/src/OmsorgCore.Application/Services/UserService.cs new file mode 100644 index 0000000..0a7520a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/UserService.cs @@ -0,0 +1,222 @@ +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Application.Services; + +public class UserService : IUserService +{ + private readonly IUserRepository _userRepository; + private readonly IEmployeeRepository _employeeRepository; + private readonly IRoleRepository _roleRepository; + private readonly IPasswordHasher _passwordHasher; + private readonly IPasswordResetService _passwordResetService; + private readonly IRefreshTokenRepository _refreshTokenRepository; + + public UserService( + IUserRepository userRepository, + IEmployeeRepository employeeRepository, + IRoleRepository roleRepository, + IPasswordHasher passwordHasher, + IPasswordResetService passwordResetService, + IRefreshTokenRepository refreshTokenRepository) + { + _userRepository = userRepository; + _employeeRepository = employeeRepository; + _roleRepository = roleRepository; + _passwordHasher = passwordHasher; + _passwordResetService = passwordResetService; + _refreshTokenRepository = refreshTokenRepository; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + { + var users = await _userRepository.GetAllAsync(cancellationToken); + return users + .Select(u => new UserSummary(u.Id, u.Username, u.EmployeeId, u.Role.Name, u.IsActive, u.MustChangePassword)) + .ToList(); + } + + public async Task CreateForEmployeeAsync( + Guid employeeId, + string username, + Guid roleId, + UserCreationMode mode, + string? initialPassword, + TimeSpan? invitePinValidity, + CancellationToken cancellationToken = default) + { + var employee = await _employeeRepository.GetByIdAsync(employeeId, cancellationToken); + if (employee is null) + { + return CreateUserResult.Fail(CreateUserFailureReason.EmployeeNotFound); + } + + var existingForEmployee = await _userRepository.GetByEmployeeIdAsync(employeeId, cancellationToken); + if (existingForEmployee is not null) + { + return CreateUserResult.Fail(CreateUserFailureReason.EmployeeAlreadyHasAccount); + } + + if (await _userRepository.ExistsByUsernameAsync(username, cancellationToken)) + { + return CreateUserResult.Fail(CreateUserFailureReason.UsernameTaken); + } + + var role = await _roleRepository.GetByIdAsync(roleId, cancellationToken); + if (role is null) + { + return CreateUserResult.Fail(CreateUserFailureReason.RoleNotFound); + } + + if (mode == UserCreationMode.Direct && string.IsNullOrWhiteSpace(initialPassword)) + { + return CreateUserResult.Fail(CreateUserFailureReason.InitialPasswordRequired); + } + + if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(employee.Email)) + { + return CreateUserResult.Fail(CreateUserFailureReason.EmployeeEmailMissing); + } + + var user = new User + { + Username = username, + RoleId = roleId, + EmployeeId = employeeId, + // Invite-Modus: kein für den Login nutzbares Passwort - Hash eines Zufallswerts, den niemand + // je eingibt. Der Mitarbeiter setzt sein eigenes Passwort über den unten ausgestellten PIN-Flow. + PasswordHash = mode == UserCreationMode.Direct + ? _passwordHasher.Hash(initialPassword!) + : _passwordHasher.Hash(Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N")), + MustChangePassword = mode == UserCreationMode.Direct + }; + + await _userRepository.AddAsync(user, cancellationToken); + await _userRepository.SaveChangesAsync(cancellationToken); + + if (mode == UserCreationMode.Invite) + { + var validity = invitePinValidity ?? TimeSpan.FromDays(7); + var rawPin = await _passwordResetService.IssueInviteCodeAsync(user.Id, validity, cancellationToken); + return CreateUserResult.Ok(user.Id, role.Name, employee.Email, rawPin); + } + + return CreateUserResult.Ok(user.Id, role.Name); + } + + public async Task ChangeOwnPasswordAsync( + Guid userId, + string currentPassword, + string newPassword, + CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdAsync(userId, cancellationToken); + if (user is null || !_passwordHasher.Verify(currentPassword, user.PasswordHash)) + { + return false; + } + + user.PasswordHash = _passwordHasher.Hash(newPassword); + user.MustChangePassword = false; + + // Wie nach einem Passwort-Reset: bestehende Sessions beenden, SecurityStamp erneuern - der + // gerade benutzte Access-Token wird dadurch ab dem nächsten Request ungültig, ein neuer Login + // mit dem neuen Passwort ist danach nötig (siehe PasswordResetService.ResetPasswordAsync). + var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken); + foreach (var session in activeSessions) + { + session.RevokedAt = DateTime.UtcNow; + } + user.SecurityStamp = Guid.NewGuid(); + + await _userRepository.SaveChangesAsync(cancellationToken); + await _refreshTokenRepository.SaveChangesAsync(cancellationToken); + return true; + } + + public async Task AdminResetPasswordAsync( + Guid userId, + UserCreationMode mode, + string? initialPassword, + TimeSpan? invitePinValidity, + CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdWithEmployeeAsync(userId, cancellationToken); + if (user is null) + { + return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.UserNotFound); + } + + if (mode == UserCreationMode.Direct && string.IsNullOrWhiteSpace(initialPassword)) + { + return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.InitialPasswordRequired); + } + + if (mode == UserCreationMode.Invite && string.IsNullOrWhiteSpace(user.Employee?.Email)) + { + return AdminResetPasswordResult.Fail(AdminResetPasswordFailureReason.EmployeeEmailMissing); + } + + if (mode == UserCreationMode.Direct) + { + user.PasswordHash = _passwordHasher.Hash(initialPassword!); + user.MustChangePassword = true; + + // Admin-gesetztes Passwort ersetzt sofort alle laufenden Sessions - wie ein regulärer + // Passwort-Reset (siehe PasswordResetService.ResetPasswordAsync). + var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken); + foreach (var session in activeSessions) + { + session.RevokedAt = DateTime.UtcNow; + } + user.SecurityStamp = Guid.NewGuid(); + + await _userRepository.SaveChangesAsync(cancellationToken); + await _refreshTokenRepository.SaveChangesAsync(cancellationToken); + return AdminResetPasswordResult.Ok(); + } + + var validity = invitePinValidity ?? TimeSpan.FromDays(7); + var rawPin = await _passwordResetService.IssueInviteCodeAsync(user.Id, validity, cancellationToken); + return AdminResetPasswordResult.Ok(user.Employee!.Email, rawPin); + } + + public async Task UpdateAsync( + Guid userId, + Guid roleId, + bool isActive, + CancellationToken cancellationToken = default) + { + var user = await _userRepository.GetByIdAsync(userId, cancellationToken); + if (user is null) + { + return UpdateUserResult.Fail(UpdateUserFailureReason.UserNotFound); + } + + var role = await _roleRepository.GetByIdAsync(roleId, cancellationToken); + if (role is null) + { + return UpdateUserResult.Fail(UpdateUserFailureReason.RoleNotFound); + } + + user.RoleId = roleId; + + var wasActive = user.IsActive; + user.IsActive = isActive; + + if (wasActive && !isActive) + { + // Sofortiger Killswitch bei Deaktivierung - kein Warten auf Token-Ablauf (siehe SessionAdminService). + var activeSessions = await _refreshTokenRepository.GetActiveByUserIdAsync(userId, cancellationToken); + foreach (var session in activeSessions) + { + session.RevokedAt = DateTime.UtcNow; + } + user.SecurityStamp = Guid.NewGuid(); + await _refreshTokenRepository.SaveChangesAsync(cancellationToken); + } + + await _userRepository.SaveChangesAsync(cancellationToken); + return UpdateUserResult.Ok(); + } +} diff --git a/omsorgCore/src/OmsorgCore.Application/Services/UserSummary.cs b/omsorgCore/src/OmsorgCore.Application/Services/UserSummary.cs new file mode 100644 index 0000000..8ab3a67 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Application/Services/UserSummary.cs @@ -0,0 +1,4 @@ +namespace OmsorgCore.Application.Services; + +/// Für die Account-Übersicht (Mitarbeiter mit/ohne Login-Konto) - siehe UsersController. +public record UserSummary(Guid Id, string Username, Guid? EmployeeId, string RoleName, bool IsActive, bool MustChangePassword); diff --git a/omsorgCore/src/OmsorgCore.Domain/Common/AuditableEntity.cs b/omsorgCore/src/OmsorgCore.Domain/Common/AuditableEntity.cs new file mode 100644 index 0000000..c6f0217 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Common/AuditableEntity.cs @@ -0,0 +1,11 @@ +namespace OmsorgCore.Domain.Common; + +public abstract class AuditableEntity : Entity +{ + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? UpdatedAt { get; set; } + + // Soft-Delete statt Hard-Delete für geschäftsrelevante Daten (siehe CLAUDE.md). + public bool IsDeleted { get; set; } + public DateTime? DeletedAt { get; set; } +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Common/Entity.cs b/omsorgCore/src/OmsorgCore.Domain/Common/Entity.cs new file mode 100644 index 0000000..a381ca3 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Common/Entity.cs @@ -0,0 +1,6 @@ +namespace OmsorgCore.Domain.Common; + +public abstract class Entity +{ + public Guid Id { get; protected set; } = Guid.NewGuid(); +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Contract.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Contract.cs new file mode 100644 index 0000000..d848d86 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Contract.cs @@ -0,0 +1,22 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Core-Objekt "Vertrag" — verbindliche Vereinbarung mit Mitarbeiter oder Einrichtung +/// (REQUIREMENTS.md Abschnitt 6, Blueprint 19.3). +/// +public class Contract : AuditableEntity +{ + public string ContractType { get; set; } = string.Empty; + + public Guid? EmployeeId { get; set; } + public Employee? Employee { get; set; } + + public Guid? FacilityId { get; set; } + public Facility? Facility { get; set; } + + public DateOnly StartDate { get; set; } + public DateOnly? EndDate { get; set; } + public string Status { get; set; } = "Entwurf"; +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Employee.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Employee.cs new file mode 100644 index 0000000..dc50ecb --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Employee.cs @@ -0,0 +1,32 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Core-Objekt "Mitarbeiter" — digitale Personalakte (REQUIREMENTS.md Abschnitt 6, Blueprint 19.1). +/// Kernfelder für das Fundament; Ausbau (Qualifikationen, Fortbildungen, Dokumente, ...) folgt in +/// späteren Schritten. +/// +public class Employee : AuditableEntity +{ + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public DateOnly? DateOfBirth { get; set; } + public string? Street { get; set; } + public string? PostalCode { get; set; } + public string? City { get; set; } + public string? Country { get; set; } + public string? PhoneNumber { get; set; } + public string? Email { get; set; } + public DateOnly? EntryDate { get; set; } + public DateOnly? ExitDate { get; set; } + public string Status { get; set; } = "Aktiv"; + public string? EmergencyContactName { get; set; } + public string? EmergencyContactPhone { get; set; } + public string? EmergencyContactRelation { get; set; } + public string? EmploymentType { get; set; } + public string? Qualification { get; set; } + + /// Dateiname des Profilbilds - die Datei selbst liegt weiterhin lokal beim jeweiligen Client (z. B. omsorgWeb/assets/avatars/), hier nur die Zuordnung. + public string? AvatarFileName { get; set; } +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs new file mode 100644 index 0000000..8e8d98b --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Facility.cs @@ -0,0 +1,17 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Core-Objekt "Einrichtung" — CRM-/Kundendatensatz (REQUIREMENTS.md Abschnitt 6, Blueprint 19.2). +/// +public class Facility : AuditableEntity +{ + public string Name { get; set; } = string.Empty; + public string? FacilityType { get; set; } + public string? Address { get; set; } + public string? BillingAddress { get; set; } + public string? PhoneNumber { get; set; } + public string? Email { get; set; } + public string CrmStatus { get; set; } = "Lead"; +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Invoice.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Invoice.cs new file mode 100644 index 0000000..ea00ed3 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Invoice.cs @@ -0,0 +1,24 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Core-Objekt "Rechnung" — entsteht ausschließlich aus freigegebener Zeiterfassung + Konditionen +/// der Einrichtung (REQUIREMENTS.md Abschnitt 6, Blueprint 19.6, Regel FR-RE-1/FR-ZE-3). +/// +public class Invoice : AuditableEntity +{ + public string InvoiceNumber { get; set; } = string.Empty; + + public Guid FacilityId { get; set; } + public Facility Facility { get; set; } = null!; + + public DateOnly BillingPeriodStart { get; set; } + public DateOnly BillingPeriodEnd { get; set; } + + public decimal NetAmount { get; set; } + public decimal GrossAmount { get; set; } + + public string Status { get; set; } = "Entwurf"; + public DateOnly? DueDate { get; set; } +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/LoginAttempt.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/LoginAttempt.cs new file mode 100644 index 0000000..860d6c6 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/LoginAttempt.cs @@ -0,0 +1,15 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Protokolliert jeden Login-Versuch (Erfolg und Fehlschlag) IP-basiert - Grundlage für die +/// zentrale Brute-Force-Sperre in . +/// +public class LoginAttempt : Entity +{ + public string IpAddress { get; set; } = string.Empty; + public string Username { get; set; } = string.Empty; + public bool Succeeded { get; set; } + public DateTime AttemptedAt { get; set; } = DateTime.UtcNow; +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Order.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Order.cs new file mode 100644 index 0000000..4a4c0ef --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Order.cs @@ -0,0 +1,17 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Core-Objekt "Auftrag" — Personalbedarf einer Einrichtung (REQUIREMENTS.md Abschnitt 6, Blueprint 19.4). +/// +public class Order : AuditableEntity +{ + public Guid FacilityId { get; set; } + public Facility Facility { get; set; } = null!; + + public DateOnly StartDate { get; set; } + public DateOnly? EndDate { get; set; } + public string? RequiredQualification { get; set; } + public string Status { get; set; } = "Anfrage"; +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/PasswordResetCode.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/PasswordResetCode.cs new file mode 100644 index 0000000..07b96a4 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/PasswordResetCode.cs @@ -0,0 +1,35 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Einmal-PIN für den "Passwort vergessen"-Flow. Es wird nie der rohe 6-stellige PIN gespeichert, +/// nur sein SHA-256-Hash (analog RefreshToken.TokenHash). Nach erfolgreicher Verifikation wird ein +/// separater, ebenfalls nur gehashter Reset-Token ausgestellt (ResetTokenHash), der für den +/// abschließenden Passwort-Set-Aufruf gebraucht wird - der PIN selbst wird nie zweimal als +/// Credential verwendet (siehe RefreshToken-Rotationsmuster). +/// +public class PasswordResetCode : Entity +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public string CodeHash { get; set; } = string.Empty; + public int AttemptCount { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + public DateTime? ConsumedAt { get; set; } + public DateTime? InvalidatedAt { get; set; } + + /// Nur gesetzt, nachdem der PIN erfolgreich verifiziert wurde (ConsumedAt != null). + public string? ResetTokenHash { get; set; } + public DateTime? ResetTokenExpiresAt { get; set; } + public DateTime? ResetTokenUsedAt { get; set; } + + public bool IsActive => InvalidatedAt is null && ConsumedAt is null + && DateTime.UtcNow < ExpiresAt; + + public bool IsResetTokenActive => ResetTokenHash is not null && ResetTokenUsedAt is null + && ResetTokenExpiresAt is not null && DateTime.UtcNow < ResetTokenExpiresAt; +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/RefreshToken.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/RefreshToken.cs new file mode 100644 index 0000000..08e3ff9 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/RefreshToken.cs @@ -0,0 +1,22 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Langlebiges Gegenstück zum kurzlebigen JWT-Access-Token. Rotiert bei jeder Nutzung +/// (), damit ein Nutzer nicht täglich neu einloggen muss. +/// Es wird nie der Rohtoken gespeichert, nur sein Hash. +/// +public class RefreshToken : Entity +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public string TokenHash { get; set; } = string.Empty; + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime? RevokedAt { get; set; } + public Guid? ReplacedByTokenId { get; set; } + + public bool IsActive => RevokedAt is null && DateTime.UtcNow < ExpiresAt; +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/Role.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/Role.cs new file mode 100644 index 0000000..6aaa081 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/Role.cs @@ -0,0 +1,15 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Rolle als Rechte-Vorlage (z. B. Geschäftsführung, Disposition, Recruiting, Außendienst). +/// Siehe REQUIREMENTS.md Abschnitt 3 und 7. +/// +public class Role : Entity +{ + public string Name { get; set; } = string.Empty; + + public ICollection RolePermissions { get; set; } = new List(); + public ICollection Users { get; set; } = new List(); +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/RolePermission.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/RolePermission.cs new file mode 100644 index 0000000..e565240 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/RolePermission.cs @@ -0,0 +1,16 @@ +using OmsorgCore.Domain.Common; +using OmsorgCore.Domain.Enums; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Standard-Recht einer Rolle für ein Modul/eine Aktion. +/// +public class RolePermission : Entity +{ + public Guid RoleId { get; set; } + public Role Role { get; set; } = null!; + + public ModuleType Module { get; set; } + public PermissionAction Action { get; set; } +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/TimeEntry.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/TimeEntry.cs new file mode 100644 index 0000000..1042f70 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/TimeEntry.cs @@ -0,0 +1,24 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Core-Objekt "Zeiterfassung" — geleistete Arbeitszeit eines Mitarbeiters innerhalb eines Auftrags +/// (REQUIREMENTS.md Abschnitt 6, Blueprint 19.5). Nur freigegebene Einträge (Status "Freigegeben") +/// dürfen in die Rechnungserstellung einfließen (FR-ZE-3). +/// +public class TimeEntry : AuditableEntity +{ + public Guid EmployeeId { get; set; } + public Employee Employee { get; set; } = null!; + + public Guid OrderId { get; set; } + public Order Order { get; set; } = null!; + + public DateOnly Date { get; set; } + public TimeOnly Start { get; set; } + public TimeOnly End { get; set; } + public TimeSpan BreakDuration { get; set; } + + public string Status { get; set; } = "Entwurf"; +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/User.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/User.cs new file mode 100644 index 0000000..dfa91a1 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/User.cs @@ -0,0 +1,36 @@ +using OmsorgCore.Domain.Common; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Systembenutzer (Login-Konto). Getrennt von , da nicht jeder Mitarbeiter +/// einen App-Zugang hat und nicht jeder Benutzer zwingend ein Mitarbeiter ist (siehe Blueprint 19.1). +/// +public class User : AuditableEntity +{ + public string Username { get; set; } = string.Empty; + public string PasswordHash { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + + /// + /// Wechselt, wenn eine Session dieses Users erzwungen beendet wird (siehe SessionAdminService). + /// Wird als Claim in jeden Access-Token eingebettet und bei jedem Request dagegen geprüft - + /// ein Wechsel invalidiert sofort alle bereits ausgestellten Access-Tokens dieses Users. + /// + public Guid SecurityStamp { get; set; } = Guid.NewGuid(); + + /// + /// Erzwingt beim nächsten Request (außer Passwort-ändern/Logout/Refresh/Me) einen Passwortwechsel - + /// gesetzt, wenn ein Admin ein initiales Passwort direkt vergibt statt eine Einladungsmail zu senden. + /// + public bool MustChangePassword { get; set; } + + public Guid RoleId { get; set; } + public Role Role { get; set; } = null!; + + /// Optionale Verknüpfung zum Personalakte-Datensatz (nicht jeder Benutzer ist Mitarbeiter). + public Guid? EmployeeId { get; set; } + public Employee? Employee { get; set; } + + public ICollection PermissionOverrides { get; set; } = new List(); +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Entities/UserPermissionOverride.cs b/omsorgCore/src/OmsorgCore.Domain/Entities/UserPermissionOverride.cs new file mode 100644 index 0000000..0054505 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Entities/UserPermissionOverride.cs @@ -0,0 +1,19 @@ +using OmsorgCore.Domain.Common; +using OmsorgCore.Domain.Enums; + +namespace OmsorgCore.Domain.Entities; + +/// +/// Individuell für einen Benutzer gewährtes oder entzogenes Recht — überschreibt den Rollen-Default. +/// Siehe Blueprint Kap. 6.5 ("Rollen dienen als Vorlage. Individuelle Rechte können zusätzlich +/// vergeben oder entzogen werden."). +/// +public class UserPermissionOverride : Entity +{ + public Guid UserId { get; set; } + public User User { get; set; } = null!; + + public ModuleType Module { get; set; } + public PermissionAction Action { get; set; } + public PermissionEffect Effect { get; set; } +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs b/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs new file mode 100644 index 0000000..5741cd8 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Enums/ModuleType.cs @@ -0,0 +1,17 @@ +namespace OmsorgCore.Domain.Enums; + +/// +/// Fachliche Module, gegen die Rechte geprüft werden (siehe REQUIREMENTS.md Abschnitt 7, Rechtematrix). +/// +public enum ModuleType +{ + Employees, + Facilities, + Contracts, + Orders, + TimeEntries, + Invoices, + Recruiting, + Controlling, + UserManagement +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionAction.cs b/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionAction.cs new file mode 100644 index 0000000..bd28035 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionAction.cs @@ -0,0 +1,14 @@ +namespace OmsorgCore.Domain.Enums; + +/// +/// Mögliche Aktionen innerhalb eines Moduls (siehe Blueprint Kap. 6.5). +/// +public enum PermissionAction +{ + View, + Create, + Edit, + Delete, + Export, + Approve +} diff --git a/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionEffect.cs b/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionEffect.cs new file mode 100644 index 0000000..649782a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/Enums/PermissionEffect.cs @@ -0,0 +1,10 @@ +namespace OmsorgCore.Domain.Enums; + +/// +/// Ob ein individueller Permission-Override ein Recht zusätzlich gewährt oder entzieht. +/// +public enum PermissionEffect +{ + Grant, + Revoke +} diff --git a/omsorgCore/src/OmsorgCore.Domain/OmsorgCore.Domain.csproj b/omsorgCore/src/OmsorgCore.Domain/OmsorgCore.Domain.csproj new file mode 100644 index 0000000..fa71b7a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Domain/OmsorgCore.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/omsorgCore/src/OmsorgCore.Email/ConsoleEmailSender.cs b/omsorgCore/src/OmsorgCore.Email/ConsoleEmailSender.cs new file mode 100644 index 0000000..14f03b6 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/ConsoleEmailSender.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Logging; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Email; + +/// +/// Entwicklungs-Sender: schreibt E-Mails nur ins Log statt sie tatsächlich zu versenden. +/// Bewusster Default, solange kein echter SMTP-Provider registriert ist (siehe AddEmail). +/// +public class ConsoleEmailSender : IEmailSender +{ + private readonly ILogger _logger; + + public ConsoleEmailSender(ILogger logger) + { + _logger = logger; + } + + public Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "[ConsoleEmailSender] An: {To} | Betreff: {Subject}\n{Body}", + message.ToAddress, message.Subject, message.PlainTextBody); + return Task.CompletedTask; + } +} diff --git a/omsorgCore/src/OmsorgCore.Email/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Email/DependencyInjection.cs new file mode 100644 index 0000000..1b342bd --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/DependencyInjection.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Email; + +public static class DependencyInjection +{ + public static IServiceCollection AddEmail(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(EmailOptions.SectionName)); + + var provider = configuration.GetSection(EmailOptions.SectionName)["Provider"]; + if (string.Equals(provider, "Smtp", StringComparison.OrdinalIgnoreCase)) + { + services.AddScoped(); + } + else + { + services.AddScoped(); + } + + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/omsorgCore/src/OmsorgCore.Email/EmailAccountOptions.cs b/omsorgCore/src/OmsorgCore.Email/EmailAccountOptions.cs new file mode 100644 index 0000000..81c4fa2 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/EmailAccountOptions.cs @@ -0,0 +1,14 @@ +namespace OmsorgCore.Email; + +/// +/// Ein Mail-Account (Login + Absenderidentität) auf dem gemeinsamen SMTP-Server (). +/// Username/Password sind Secrets - im eingecheckten appsettings nur "CHANGE_ME_..."-Platzhalter, +/// echte Werte über Umgebungsvariable (Email__Accounts__<Name>__Password) oder dotnet user-secrets. +/// +public class EmailAccountOptions +{ + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string SenderAddress { get; set; } = string.Empty; + public string SenderDisplayName { get; set; } = "OMSORG"; +} diff --git a/omsorgCore/src/OmsorgCore.Email/EmailOptions.cs b/omsorgCore/src/OmsorgCore.Email/EmailOptions.cs new file mode 100644 index 0000000..4494a87 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/EmailOptions.cs @@ -0,0 +1,36 @@ +namespace OmsorgCore.Email; + +/// +/// Gebunden an den "Email"-Abschnitt in appsettings.json. Ein gemeinsamer SMTP-Server (), +/// darüber mehrere benannte Accounts () - z. B. ein dedizierter Account nur für +/// Passwort-Reset-Mails, getrennt von einem künftigen allgemeinen Absender. +/// +public class EmailOptions +{ + public const string SectionName = "Email"; + + /// "Console" (Default/Dev, schreibt nur ins Log) oder "Smtp" (echter Versand über ). + public string Provider { get; set; } = "Console"; + + public SmtpOptions Smtp { get; set; } = new(); + + /// Benannte Accounts auf dem SMTP-Server, Key = frei wählbarer Name (z. B. "PasswordReset"). + public Dictionary Accounts { get; set; } = new(); + + /// Welcher Eintrag aus für Passwort-Reset-Mails verwendet wird. + public string PasswordResetAccount { get; set; } = "PasswordReset"; + + public PasswordResetEmailTemplate PasswordResetTemplate { get; set; } = new(); + + /// Welcher Eintrag aus für Account-Einladungsmails verwendet wird. + public string UserInviteAccount { get; set; } = "UserInvite"; + + public UserInviteEmailTemplate UserInviteTemplate { get; set; } = new(); + + /// + /// Dupliziert bewusst OmsorgCore.Infrastructure.Security.PasswordResetOptions.PinExpiryMinutes - + /// beide binden an dieselbe "Email"-Sektion, bleiben aber unabhängige Klassen je Schicht + /// (Engine kennt Infrastructure nicht), analog dem bestehenden Kommentar in PasswordResetOptions. + /// + public int PinExpiryMinutes { get; set; } = 5; +} diff --git a/omsorgCore/src/OmsorgCore.Email/EmailTemplateRenderer.cs b/omsorgCore/src/OmsorgCore.Email/EmailTemplateRenderer.cs new file mode 100644 index 0000000..ab8b09d --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/EmailTemplateRenderer.cs @@ -0,0 +1,19 @@ +namespace OmsorgCore.Email; + +/// +/// Ersetzt Platzhalter im Format $NAME$ in einer Vorlage. Reine Funktion, keine Abhängigkeiten - +/// bewusst aus dem Handler herausgezogen, damit die Ersetzung isoliert testbar ist. +/// +public static class EmailTemplateRenderer +{ + public static string Render(string template, IReadOnlyDictionary values) + { + var result = template; + foreach (var (key, value) in values) + { + result = result.Replace($"${key}$", value); + } + + return result; + } +} diff --git a/omsorgCore/src/OmsorgCore.Email/OmsorgCore.Email.csproj b/omsorgCore/src/OmsorgCore.Email/OmsorgCore.Email.csproj new file mode 100644 index 0000000..0427d21 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/OmsorgCore.Email.csproj @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailComposer.cs b/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailComposer.cs new file mode 100644 index 0000000..846983f --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailComposer.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Email; + +public class PasswordResetEmailComposer : IPasswordResetEmailComposer +{ + private readonly EmailOptions _options; + + public PasswordResetEmailComposer(IOptions options) + { + _options = options.Value; + } + + public EmailMessage Compose(string toEmail, string rawPin) + { + var template = _options.PasswordResetTemplate; + var values = new Dictionary + { + ["RESET_PIN"] = rawPin, + ["RESET_PIN_EXPIRY_MINUTES"] = _options.PinExpiryMinutes.ToString() + }; + + return new EmailMessage( + toEmail, + EmailTemplateRenderer.Render(template.Subject, values), + EmailTemplateRenderer.Render(template.BodyTemplate, values), + FromAccountKey: _options.PasswordResetAccount); + } +} diff --git a/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailTemplate.cs b/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailTemplate.cs new file mode 100644 index 0000000..1abc9a4 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/PasswordResetEmailTemplate.cs @@ -0,0 +1,13 @@ +namespace OmsorgCore.Email; + +/// +/// Betreff/Text der Passwort-Reset-Mail, über appsettings konfigurierbar. Platzhalter im Format +/// $NAME$ (z. B. $RESET_PIN$), ersetzt von . +/// +public class PasswordResetEmailTemplate +{ + public string Subject { get; set; } = "Ihr OMSORG Passwort-Reset-Code"; + + public string BodyTemplate { get; set; } = + "Ihr Code lautet: $RESET_PIN$\nDer Code ist $RESET_PIN_EXPIRY_MINUTES$ Minuten gültig."; +} diff --git a/omsorgCore/src/OmsorgCore.Email/SmtpEmailSender.cs b/omsorgCore/src/OmsorgCore.Email/SmtpEmailSender.cs new file mode 100644 index 0000000..d645b95 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/SmtpEmailSender.cs @@ -0,0 +1,57 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MimeKit; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Email; + +/// +/// Echter Mail-Versand über SMTP (MailKit statt des veralteten System.Net.Mail.SmtpClient). +/// Ein gemeinsamer Server (), Login/Absender kommen aus dem per +/// gewählten Account () - +/// alles aus appsettings/Env-Var/user-secrets, nie vom Client. +/// +public class SmtpEmailSender : IEmailSender +{ + private readonly EmailOptions _options; + private readonly ILogger _logger; + + public SmtpEmailSender(IOptions options, ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + var accountKey = message.FromAccountKey ?? _options.PasswordResetAccount; + if (!_options.Accounts.TryGetValue(accountKey, out var account)) + { + throw new InvalidOperationException( + $"Kein E-Mail-Account mit Schlüssel '{accountKey}' unter Email:Accounts konfiguriert."); + } + + var mimeMessage = new MimeMessage(); + mimeMessage.From.Add(new MailboxAddress(account.SenderDisplayName, account.SenderAddress)); + mimeMessage.To.Add(MailboxAddress.Parse(message.ToAddress)); + mimeMessage.Subject = message.Subject; + mimeMessage.Body = new TextPart("plain") { Text = message.PlainTextBody }; + + var smtp = _options.Smtp; + + using var client = new SmtpClient(); + await client.ConnectAsync( + smtp.Host, + smtp.Port, + smtp.EnableSsl ? SecureSocketOptions.StartTlsWhenAvailable : SecureSocketOptions.None, + cancellationToken); + await client.AuthenticateAsync(account.Username, account.Password, cancellationToken); + await client.SendAsync(mimeMessage, cancellationToken); + await client.DisconnectAsync(true, cancellationToken); + + _logger.LogInformation("[SmtpEmailSender] Mail an {To} über {Host}:{Port} (Account {Account}) versendet.", + message.ToAddress, smtp.Host, smtp.Port, accountKey); + } +} diff --git a/omsorgCore/src/OmsorgCore.Email/SmtpOptions.cs b/omsorgCore/src/OmsorgCore.Email/SmtpOptions.cs new file mode 100644 index 0000000..e0d818e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/SmtpOptions.cs @@ -0,0 +1,12 @@ +namespace OmsorgCore.Email; + +/// +/// Verbindungsdaten des einen gemeinsamen SMTP-Servers, über den alle Accounts () +/// versenden. Kein Secret - Username/Passwort liegen pro Account, nicht hier. +/// +public class SmtpOptions +{ + public string Host { get; set; } = string.Empty; + public int Port { get; set; } = 587; + public bool EnableSsl { get; set; } = true; +} diff --git a/omsorgCore/src/OmsorgCore.Email/UserInviteEmailComposer.cs b/omsorgCore/src/OmsorgCore.Email/UserInviteEmailComposer.cs new file mode 100644 index 0000000..173e42c --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/UserInviteEmailComposer.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Email; + +public class UserInviteEmailComposer : IUserInviteEmailComposer +{ + private readonly EmailOptions _options; + + public UserInviteEmailComposer(IOptions options) + { + _options = options.Value; + } + + public EmailMessage Compose(string toEmail, string rawPin, int validityDays) + { + var template = _options.UserInviteTemplate; + var values = new Dictionary + { + ["INVITE_PIN"] = rawPin, + ["INVITE_PIN_EXPIRY_DAYS"] = validityDays.ToString() + }; + + return new EmailMessage( + toEmail, + EmailTemplateRenderer.Render(template.Subject, values), + EmailTemplateRenderer.Render(template.BodyTemplate, values), + FromAccountKey: _options.UserInviteAccount); + } +} diff --git a/omsorgCore/src/OmsorgCore.Email/UserInviteEmailTemplate.cs b/omsorgCore/src/OmsorgCore.Email/UserInviteEmailTemplate.cs new file mode 100644 index 0000000..fd1e1ed --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Email/UserInviteEmailTemplate.cs @@ -0,0 +1,13 @@ +namespace OmsorgCore.Email; + +/// +/// Betreff/Text der Account-Einladungsmail, über appsettings konfigurierbar. Platzhalter im Format +/// $NAME$ (z. B. $INVITE_PIN$), ersetzt von . +/// +public class UserInviteEmailTemplate +{ + public string Subject { get; set; } = "Ihr OMSORG-Zugang"; + + public string BodyTemplate { get; set; } = + "Für Sie wurde ein OMSORG-Konto angelegt.\nIhr Einladungscode lautet: $INVITE_PIN$\nDer Code ist $INVITE_PIN_EXPIRY_DAYS$ Tage gültig. Damit legen Sie beim ersten Login Ihr eigenes Passwort fest."; +} diff --git a/omsorgCore/src/OmsorgCore.Engine/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Engine/DependencyInjection.cs new file mode 100644 index 0000000..49f0590 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/DependencyInjection.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.DependencyInjection; +using OmsorgCore.Engine.Events; +using OmsorgCore.Engine.Handlers; + +namespace OmsorgCore.Engine; + +public static class DependencyInjection +{ + public static IServiceCollection AddEngine(this IServiceCollection services) + { + services.AddSingleton(); + services.AddScoped, EmployeeCreatedHandler>(); + services.AddScoped, PasswordResetRequestedHandler>(); + services.AddScoped, UserInvitedHandler>(); + + return services; + } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/DomainEventDispatcher.cs b/omsorgCore/src/OmsorgCore.Engine/Events/DomainEventDispatcher.cs new file mode 100644 index 0000000..f522f1e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Events/DomainEventDispatcher.cs @@ -0,0 +1,36 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace OmsorgCore.Engine.Events; + +/// +/// Einfacher In-Process-Dispatcher: löst alle registrierten Handler für den konkreten +/// Event-Typ über den DI-Container auf und führt sie aus. +/// +public class DomainEventDispatcher : IDomainEventDispatcher +{ + private readonly IServiceScopeFactory _serviceScopeFactory; + + public DomainEventDispatcher(IServiceScopeFactory serviceScopeFactory) + { + _serviceScopeFactory = serviceScopeFactory; + } + + public async Task DispatchAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + using var scope = _serviceScopeFactory.CreateScope(); + + var handlerType = typeof(IDomainEventHandler<>).MakeGenericType(domainEvent.GetType()); + var handlers = scope.ServiceProvider.GetServices(handlerType); + + foreach (var handler in handlers) + { + if (handler is null) + { + continue; + } + + var method = handlerType.GetMethod(nameof(IDomainEventHandler.HandleAsync))!; + await (Task)method.Invoke(handler, new object[] { domainEvent, cancellationToken })!; + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/EmployeeCreatedEvent.cs b/omsorgCore/src/OmsorgCore.Engine/Events/EmployeeCreatedEvent.cs new file mode 100644 index 0000000..c51b235 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Events/EmployeeCreatedEvent.cs @@ -0,0 +1,16 @@ +namespace OmsorgCore.Engine.Events; + +/// +/// Beispiel-Ereignis für den Trigger "Neuer Mitarbeiter beginnt" aus REQUIREMENTS.md Abschnitt 4.10. +/// +public class EmployeeCreatedEvent : IDomainEvent +{ + public Guid EmployeeId { get; } + public DateTime OccurredAt { get; } + + public EmployeeCreatedEvent(Guid employeeId) + { + EmployeeId = employeeId; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEvent.cs b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEvent.cs new file mode 100644 index 0000000..9c1d9b2 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEvent.cs @@ -0,0 +1,10 @@ +namespace OmsorgCore.Engine.Events; + +/// +/// Markerinterface für Ereignisse, auf die die Event-Schicht reagiert +/// (siehe REQUIREMENTS.md Abschnitt 4.10, Blueprint Kap. 20). +/// +public interface IDomainEvent +{ + DateTime OccurredAt { get; } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventDispatcher.cs b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventDispatcher.cs new file mode 100644 index 0000000..19fa2a3 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventDispatcher.cs @@ -0,0 +1,11 @@ +namespace OmsorgCore.Engine.Events; + +/// +/// Einstiegspunkt der Event-Schicht: die Api-Schicht ruft nach jeder geschäftsrelevanten +/// Zustandsänderung (z. B. nach dem Speichern eines neuen Mitarbeiters) DispatchAsync auf. +/// Läuft im selben Prozess wie die Datenschicht — kein separater Service, keine Queue. +/// +public interface IDomainEventDispatcher +{ + Task DispatchAsync(IDomainEvent domainEvent, CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventHandler.cs b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventHandler.cs new file mode 100644 index 0000000..ee94dda --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Events/IDomainEventHandler.cs @@ -0,0 +1,6 @@ +namespace OmsorgCore.Engine.Events; + +public interface IDomainEventHandler where TEvent : IDomainEvent +{ + Task HandleAsync(TEvent domainEvent, CancellationToken cancellationToken = default); +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/PasswordResetRequestedEvent.cs b/omsorgCore/src/OmsorgCore.Engine/Events/PasswordResetRequestedEvent.cs new file mode 100644 index 0000000..1670a1a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Events/PasswordResetRequestedEvent.cs @@ -0,0 +1,15 @@ +namespace OmsorgCore.Engine.Events; + +public class PasswordResetRequestedEvent : IDomainEvent +{ + public string ToEmail { get; } + public string RawPin { get; } + public DateTime OccurredAt { get; } + + public PasswordResetRequestedEvent(string toEmail, string rawPin) + { + ToEmail = toEmail; + RawPin = rawPin; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Events/UserInvitedEvent.cs b/omsorgCore/src/OmsorgCore.Engine/Events/UserInvitedEvent.cs new file mode 100644 index 0000000..aa7a37e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Events/UserInvitedEvent.cs @@ -0,0 +1,17 @@ +namespace OmsorgCore.Engine.Events; + +public class UserInvitedEvent : IDomainEvent +{ + public string ToEmail { get; } + public string RawPin { get; } + public int ValidityDays { get; } + public DateTime OccurredAt { get; } + + public UserInvitedEvent(string toEmail, string rawPin, int validityDays) + { + ToEmail = toEmail; + RawPin = rawPin; + ValidityDays = validityDays; + OccurredAt = DateTime.UtcNow; + } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Handlers/EmployeeCreatedHandler.cs b/omsorgCore/src/OmsorgCore.Engine/Handlers/EmployeeCreatedHandler.cs new file mode 100644 index 0000000..85734da --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Handlers/EmployeeCreatedHandler.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Logging; +using OmsorgCore.Engine.Events; + +namespace OmsorgCore.Engine.Handlers; + +/// +/// Referenzimplementierung eines Event-Handlers. Reagiert auf +/// (Trigger "Neuer Mitarbeiter beginnt", REQUIREMENTS.md Abschnitt 4.10). Protokolliert bislang nur — +/// die eigentlichen Aktionen (Dashboard-Hinweis, App-Zugang vorbereiten, ...) werden ergänzt, +/// sobald die zugehörigen Module existieren. +/// +public class EmployeeCreatedHandler : IDomainEventHandler +{ + private readonly ILogger _logger; + + public EmployeeCreatedHandler(ILogger logger) + { + _logger = logger; + } + + public Task HandleAsync(EmployeeCreatedEvent domainEvent, CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "Engine: Mitarbeiter {EmployeeId} wurde angelegt ({OccurredAt}).", + domainEvent.EmployeeId, + domainEvent.OccurredAt); + + return Task.CompletedTask; + } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Handlers/PasswordResetRequestedHandler.cs b/omsorgCore/src/OmsorgCore.Engine/Handlers/PasswordResetRequestedHandler.cs new file mode 100644 index 0000000..c997324 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Handlers/PasswordResetRequestedHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Engine.Events; + +namespace OmsorgCore.Engine.Handlers; + +public class PasswordResetRequestedHandler : IDomainEventHandler +{ + private readonly IEmailSender _emailSender; + private readonly IPasswordResetEmailComposer _emailComposer; + private readonly ILogger _logger; + + public PasswordResetRequestedHandler( + IEmailSender emailSender, + IPasswordResetEmailComposer emailComposer, + ILogger logger) + { + _emailSender = emailSender; + _emailComposer = emailComposer; + _logger = logger; + } + + public async Task HandleAsync(PasswordResetRequestedEvent domainEvent, CancellationToken cancellationToken = default) + { + var message = _emailComposer.Compose(domainEvent.ToEmail, domainEvent.RawPin); + await _emailSender.SendAsync(message, cancellationToken); + + _logger.LogInformation("Engine: Passwort-Reset-Code an {Email} versendet ({OccurredAt}).", + domainEvent.ToEmail, domainEvent.OccurredAt); + } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/Handlers/UserInvitedHandler.cs b/omsorgCore/src/OmsorgCore.Engine/Handlers/UserInvitedHandler.cs new file mode 100644 index 0000000..c4122d8 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/Handlers/UserInvitedHandler.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Logging; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Engine.Events; + +namespace OmsorgCore.Engine.Handlers; + +public class UserInvitedHandler : IDomainEventHandler +{ + private readonly IEmailSender _emailSender; + private readonly IUserInviteEmailComposer _emailComposer; + private readonly ILogger _logger; + + public UserInvitedHandler( + IEmailSender emailSender, + IUserInviteEmailComposer emailComposer, + ILogger logger) + { + _emailSender = emailSender; + _emailComposer = emailComposer; + _logger = logger; + } + + public async Task HandleAsync(UserInvitedEvent domainEvent, CancellationToken cancellationToken = default) + { + var message = _emailComposer.Compose(domainEvent.ToEmail, domainEvent.RawPin, domainEvent.ValidityDays); + await _emailSender.SendAsync(message, cancellationToken); + + _logger.LogInformation("Engine: Account-Einladung an {Email} versendet ({OccurredAt}).", + domainEvent.ToEmail, domainEvent.OccurredAt); + } +} diff --git a/omsorgCore/src/OmsorgCore.Engine/OmsorgCore.Engine.csproj b/omsorgCore/src/OmsorgCore.Engine/OmsorgCore.Engine.csproj new file mode 100644 index 0000000..b452bc5 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Engine/OmsorgCore.Engine.csproj @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs b/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..e7d104d --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/DependencyInjection.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Infrastructure.Persistence; +using OmsorgCore.Infrastructure.Repositories; +using OmsorgCore.Infrastructure.Security; + +namespace OmsorgCore.Infrastructure; + +/// +/// Bündelt die Registrierung aller Infrastructure-Implementierungen, damit Program.cs +/// nur diese eine Erweiterungsmethode aufrufen muss statt jede Klasse einzeln zu verdrahten. +/// +public static class DependencyInjection +{ + public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) + { + services.AddDbContext(options => + options.UseNpgsql(configuration.GetConnectionString("OmsorgCore"))); + + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure(configuration.GetSection(RefreshTokenOptions.SectionName)); + services.Configure(configuration.GetSection(PasswordResetOptions.SectionName)); + services.Configure(configuration.GetSection(SeedOptions.SectionName)); + services.Configure(configuration.GetSection(LoginAttemptOptions.SectionName)); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + return services; + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj b/omsorgCore/src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj new file mode 100644 index 0000000..b8bc898 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/OmsorgCore.Infrastructure.csproj @@ -0,0 +1,26 @@ + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + net8.0 + enable + enable + + + diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/ContractConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/ContractConfiguration.cs new file mode 100644 index 0000000..f68ad79 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/ContractConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class ContractConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("contracts"); + builder.HasKey(c => c.Id); + builder.Property(c => c.ContractType).IsRequired().HasMaxLength(100); + builder.Property(c => c.Status).IsRequired().HasMaxLength(50); + + builder.HasOne(c => c.Employee) + .WithMany() + .HasForeignKey(c => c.EmployeeId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(c => c.Facility) + .WithMany() + .HasForeignKey(c => c.FacilityId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs new file mode 100644 index 0000000..71c6627 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/EmployeeConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class EmployeeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("employees"); + builder.HasKey(e => e.Id); + builder.Property(e => e.FirstName).IsRequired().HasMaxLength(200); + builder.Property(e => e.LastName).IsRequired().HasMaxLength(200); + builder.Property(e => e.Status).IsRequired().HasMaxLength(50); + builder.Property(e => e.Street).HasMaxLength(200); + builder.Property(e => e.PostalCode).HasMaxLength(10); + builder.Property(e => e.City).HasMaxLength(100); + builder.Property(e => e.Country).HasMaxLength(100); + builder.Property(e => e.PhoneNumber).HasMaxLength(50); + builder.Property(e => e.Email).HasMaxLength(200); + builder.Property(e => e.EmergencyContactName).HasMaxLength(200); + builder.Property(e => e.EmergencyContactPhone).HasMaxLength(50); + builder.Property(e => e.EmergencyContactRelation).HasMaxLength(100); + builder.Property(e => e.EmploymentType).HasMaxLength(50); + builder.Property(e => e.Qualification).HasMaxLength(500); + + // Soft-gelöschte Mitarbeiter sind für alle normalen Queries unsichtbar. + builder.HasQueryFilter(e => !e.IsDeleted); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs new file mode 100644 index 0000000..6654008 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/FacilityConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class FacilityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("facilities"); + builder.HasKey(f => f.Id); + builder.Property(f => f.Name).IsRequired().HasMaxLength(300); + builder.Property(f => f.CrmStatus).IsRequired().HasMaxLength(50); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/InvoiceConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/InvoiceConfiguration.cs new file mode 100644 index 0000000..54d5f0c --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/InvoiceConfiguration.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class InvoiceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("invoices"); + builder.HasKey(i => i.Id); + builder.Property(i => i.InvoiceNumber).IsRequired().HasMaxLength(50); + builder.HasIndex(i => i.InvoiceNumber).IsUnique(); + builder.Property(i => i.Status).IsRequired().HasMaxLength(50); + builder.Property(i => i.NetAmount).HasColumnType("numeric(12,2)"); + builder.Property(i => i.GrossAmount).HasColumnType("numeric(12,2)"); + + builder.HasOne(i => i.Facility) + .WithMany() + .HasForeignKey(i => i.FacilityId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/LoginAttemptConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/LoginAttemptConfiguration.cs new file mode 100644 index 0000000..a4e3a77 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/LoginAttemptConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class LoginAttemptConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("login_attempts"); + builder.HasKey(a => a.Id); + builder.Property(a => a.IpAddress).IsRequired().HasMaxLength(64); + builder.Property(a => a.Username).IsRequired().HasMaxLength(100); + builder.HasIndex(a => new { a.IpAddress, a.AttemptedAt }); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/OrderConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/OrderConfiguration.cs new file mode 100644 index 0000000..e98ebb8 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/OrderConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class OrderConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("orders"); + builder.HasKey(o => o.Id); + builder.Property(o => o.Status).IsRequired().HasMaxLength(50); + + builder.HasOne(o => o.Facility) + .WithMany() + .HasForeignKey(o => o.FacilityId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/PasswordResetCodeConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/PasswordResetCodeConfiguration.cs new file mode 100644 index 0000000..b78dba8 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/PasswordResetCodeConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class PasswordResetCodeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("password_reset_codes"); + builder.HasKey(c => c.Id); + builder.Property(c => c.CodeHash).IsRequired().HasMaxLength(128); + builder.Property(c => c.ResetTokenHash).HasMaxLength(128); + builder.HasIndex(c => c.UserId); + builder.HasIndex(c => c.ResetTokenHash); + builder.Ignore(c => c.IsActive); + builder.Ignore(c => c.IsResetTokenActive); + + builder.HasOne(c => c.User) + .WithMany() + .HasForeignKey(c => c.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs new file mode 100644 index 0000000..ac0e278 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class RefreshTokenConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("refresh_tokens"); + builder.HasKey(t => t.Id); + builder.Property(t => t.TokenHash).IsRequired().HasMaxLength(128); + builder.HasIndex(t => t.TokenHash).IsUnique(); + builder.HasIndex(t => t.UserId); + builder.Ignore(t => t.IsActive); + + builder.HasOne(t => t.User) + .WithMany() + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(t => t.ReplacedByTokenId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RoleConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RoleConfiguration.cs new file mode 100644 index 0000000..16dc96f --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RoleConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class RoleConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("roles"); + builder.HasKey(r => r.Id); + builder.Property(r => r.Name).IsRequired().HasMaxLength(100); + builder.HasIndex(r => r.Name).IsUnique(); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RolePermissionConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RolePermissionConfiguration.cs new file mode 100644 index 0000000..cea9456 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/RolePermissionConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class RolePermissionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("role_permissions"); + builder.HasKey(rp => rp.Id); + builder.HasIndex(rp => new { rp.RoleId, rp.Module, rp.Action }).IsUnique(); + + builder.HasOne(rp => rp.Role) + .WithMany(r => r.RolePermissions) + .HasForeignKey(rp => rp.RoleId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/TimeEntryConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/TimeEntryConfiguration.cs new file mode 100644 index 0000000..2122ce9 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/TimeEntryConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class TimeEntryConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("time_entries"); + builder.HasKey(t => t.Id); + builder.Property(t => t.Status).IsRequired().HasMaxLength(50); + + builder.HasOne(t => t.Employee) + .WithMany() + .HasForeignKey(t => t.EmployeeId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(t => t.Order) + .WithMany() + .HasForeignKey(t => t.OrderId) + .OnDelete(DeleteBehavior.Restrict); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserConfiguration.cs new file mode 100644 index 0000000..e44a436 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); + builder.HasKey(u => u.Id); + builder.Property(u => u.Username).IsRequired().HasMaxLength(100); + builder.HasIndex(u => u.Username).IsUnique(); + builder.Property(u => u.PasswordHash).IsRequired(); + builder.Property(u => u.SecurityStamp).IsRequired(); + + builder.HasOne(u => u.Role) + .WithMany(r => r.Users) + .HasForeignKey(u => u.RoleId) + .OnDelete(DeleteBehavior.Restrict); + + builder.HasOne(u => u.Employee) + .WithMany() + .HasForeignKey(u => u.EmployeeId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserPermissionOverrideConfiguration.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserPermissionOverrideConfiguration.cs new file mode 100644 index 0000000..f76c2b3 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Configurations/UserPermissionOverrideConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence.Configurations; + +public class UserPermissionOverrideConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("user_permission_overrides"); + builder.HasKey(o => o.Id); + builder.HasIndex(o => new { o.UserId, o.Module, o.Action }).IsUnique(); + + builder.HasOne(o => o.User) + .WithMany(u => u.PermissionOverrides) + .HasForeignKey(o => o.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs new file mode 100644 index 0000000..5ef3d8b --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/DbSeeder.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Domain.Enums; +using OmsorgCore.Infrastructure.Security; + +namespace OmsorgCore.Infrastructure.Persistence; + +/// +/// Legt beim ersten Start einen Standard-Administrator an, damit ein Login überhaupt möglich ist, +/// solange es noch keinen echten Registrierungs-/User-Management-Flow gibt. Idempotent (prüft, ob +/// bereits Benutzer existieren) und bewusst nur für Development gedacht - siehe Program.cs. +/// +public static class DbSeeder +{ + private const string AdministratorRoleName = "Administrator"; + + public static async Task SeedDefaultAdminAsync(OmsorgCoreDbContext db, IPasswordHasher passwordHasher, SeedOptions options, CancellationToken cancellationToken = default) + { + if (await db.Users.AnyAsync(cancellationToken)) + { + return; + } + + var role = await db.Roles.FirstOrDefaultAsync(r => r.Name == AdministratorRoleName, cancellationToken); + if (role is null) + { + role = new Role { Name = AdministratorRoleName }; + db.Roles.Add(role); + + foreach (ModuleType module in Enum.GetValues()) + { + foreach (PermissionAction action in Enum.GetValues()) + { + role.RolePermissions.Add(new RolePermission { Role = role, Module = module, Action = action }); + } + } + } + + db.Users.Add(new User + { + Username = options.AdminUsername, + PasswordHash = passwordHasher.Hash(options.AdminPassword), + IsActive = true, + Role = role + }); + + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.Designer.cs new file mode 100644 index 0000000..8e3d3fb --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.Designer.cs @@ -0,0 +1,515 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260804154905_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.cs new file mode 100644 index 0000000..50c2337 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804154905_InitialCreate.cs @@ -0,0 +1,358 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "employees", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FirstName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + LastName = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + DateOfBirth = table.Column(type: "date", nullable: true), + Address = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + Email = table.Column(type: "text", nullable: true), + EntryDate = table.Column(type: "date", nullable: true), + ExitDate = table.Column(type: "date", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_employees", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "facilities", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(300)", maxLength: 300, nullable: false), + FacilityType = table.Column(type: "text", nullable: true), + Address = table.Column(type: "text", nullable: true), + BillingAddress = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + Email = table.Column(type: "text", nullable: true), + CrmStatus = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_facilities", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "roles", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(100)", maxLength: 100, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "contracts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ContractType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + EmployeeId = table.Column(type: "uuid", nullable: true), + FacilityId = table.Column(type: "uuid", nullable: true), + StartDate = table.Column(type: "date", nullable: false), + EndDate = table.Column(type: "date", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_contracts", x => x.Id); + table.ForeignKey( + name: "FK_contracts_employees_EmployeeId", + column: x => x.EmployeeId, + principalTable: "employees", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_contracts_facilities_FacilityId", + column: x => x.FacilityId, + principalTable: "facilities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "invoices", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + InvoiceNumber = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + FacilityId = table.Column(type: "uuid", nullable: false), + BillingPeriodStart = table.Column(type: "date", nullable: false), + BillingPeriodEnd = table.Column(type: "date", nullable: false), + NetAmount = table.Column(type: "numeric(12,2)", nullable: false), + GrossAmount = table.Column(type: "numeric(12,2)", nullable: false), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + DueDate = table.Column(type: "date", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_invoices", x => x.Id); + table.ForeignKey( + name: "FK_invoices_facilities_FacilityId", + column: x => x.FacilityId, + principalTable: "facilities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "orders", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + FacilityId = table.Column(type: "uuid", nullable: false), + StartDate = table.Column(type: "date", nullable: false), + EndDate = table.Column(type: "date", nullable: true), + RequiredQualification = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_orders", x => x.Id); + table.ForeignKey( + name: "FK_orders_facilities_FacilityId", + column: x => x.FacilityId, + principalTable: "facilities", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "role_permissions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + Module = table.Column(type: "integer", nullable: false), + Action = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_role_permissions", x => x.Id); + table.ForeignKey( + name: "FK_role_permissions_roles_RoleId", + column: x => x.RoleId, + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "users", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + PasswordHash = table.Column(type: "text", nullable: false), + IsActive = table.Column(type: "boolean", nullable: false), + RoleId = table.Column(type: "uuid", nullable: false), + EmployeeId = table.Column(type: "uuid", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_users", x => x.Id); + table.ForeignKey( + name: "FK_users_employees_EmployeeId", + column: x => x.EmployeeId, + principalTable: "employees", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_users_roles_RoleId", + column: x => x.RoleId, + principalTable: "roles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "time_entries", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + EmployeeId = table.Column(type: "uuid", nullable: false), + OrderId = table.Column(type: "uuid", nullable: false), + Date = table.Column(type: "date", nullable: false), + Start = table.Column(type: "time without time zone", nullable: false), + End = table.Column(type: "time without time zone", nullable: false), + BreakDuration = table.Column(type: "interval", nullable: false), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_time_entries", x => x.Id); + table.ForeignKey( + name: "FK_time_entries_employees_EmployeeId", + column: x => x.EmployeeId, + principalTable: "employees", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_time_entries_orders_OrderId", + column: x => x.OrderId, + principalTable: "orders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "user_permission_overrides", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Module = table.Column(type: "integer", nullable: false), + Action = table.Column(type: "integer", nullable: false), + Effect = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_user_permission_overrides", x => x.Id); + table.ForeignKey( + name: "FK_user_permission_overrides_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_contracts_EmployeeId", + table: "contracts", + column: "EmployeeId"); + + migrationBuilder.CreateIndex( + name: "IX_contracts_FacilityId", + table: "contracts", + column: "FacilityId"); + + migrationBuilder.CreateIndex( + name: "IX_invoices_FacilityId", + table: "invoices", + column: "FacilityId"); + + migrationBuilder.CreateIndex( + name: "IX_invoices_InvoiceNumber", + table: "invoices", + column: "InvoiceNumber", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_orders_FacilityId", + table: "orders", + column: "FacilityId"); + + migrationBuilder.CreateIndex( + name: "IX_role_permissions_RoleId_Module_Action", + table: "role_permissions", + columns: new[] { "RoleId", "Module", "Action" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_roles_Name", + table: "roles", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_time_entries_EmployeeId", + table: "time_entries", + column: "EmployeeId"); + + migrationBuilder.CreateIndex( + name: "IX_time_entries_OrderId", + table: "time_entries", + column: "OrderId"); + + migrationBuilder.CreateIndex( + name: "IX_user_permission_overrides_UserId_Module_Action", + table: "user_permission_overrides", + columns: new[] { "UserId", "Module", "Action" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_users_EmployeeId", + table: "users", + column: "EmployeeId"); + + migrationBuilder.CreateIndex( + name: "IX_users_RoleId", + table: "users", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_users_Username", + table: "users", + column: "Username", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "contracts"); + + migrationBuilder.DropTable( + name: "invoices"); + + migrationBuilder.DropTable( + name: "role_permissions"); + + migrationBuilder.DropTable( + name: "time_entries"); + + migrationBuilder.DropTable( + name: "user_permission_overrides"); + + migrationBuilder.DropTable( + name: "orders"); + + migrationBuilder.DropTable( + name: "users"); + + migrationBuilder.DropTable( + name: "facilities"); + + migrationBuilder.DropTable( + name: "employees"); + + migrationBuilder.DropTable( + name: "roles"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804183457_AddRefreshTokens.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804183457_AddRefreshTokens.Designer.cs new file mode 100644 index 0000000..37b843a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804183457_AddRefreshTokens.Designer.cs @@ -0,0 +1,569 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260804183457_AddRefreshTokens")] + partial class AddRefreshTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804183457_AddRefreshTokens.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804183457_AddRefreshTokens.cs new file mode 100644 index 0000000..1ddc34e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804183457_AddRefreshTokens.cs @@ -0,0 +1,67 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddRefreshTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "refresh_tokens", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + TokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true), + ReplacedByTokenId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_refresh_tokens", x => x.Id); + table.ForeignKey( + name: "FK_refresh_tokens_refresh_tokens_ReplacedByTokenId", + column: x => x.ReplacedByTokenId, + principalTable: "refresh_tokens", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_refresh_tokens_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_ReplacedByTokenId", + table: "refresh_tokens", + column: "ReplacedByTokenId"); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_TokenHash", + table: "refresh_tokens", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_refresh_tokens_UserId", + table: "refresh_tokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "refresh_tokens"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804192810_AddUserSecurityStamp.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804192810_AddUserSecurityStamp.Designer.cs new file mode 100644 index 0000000..c11b82f --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804192810_AddUserSecurityStamp.Designer.cs @@ -0,0 +1,572 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260804192810_AddUserSecurityStamp")] + partial class AddUserSecurityStamp + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804192810_AddUserSecurityStamp.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804192810_AddUserSecurityStamp.cs new file mode 100644 index 0000000..eb76499 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260804192810_AddUserSecurityStamp.cs @@ -0,0 +1,30 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserSecurityStamp : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SecurityStamp", + table: "users", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SecurityStamp", + table: "users"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805092305_AddEmployeeContactFieldConstraints.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805092305_AddEmployeeContactFieldConstraints.Designer.cs new file mode 100644 index 0000000..86c46ed --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805092305_AddEmployeeContactFieldConstraints.Designer.cs @@ -0,0 +1,575 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260805092305_AddEmployeeContactFieldConstraints")] + partial class AddEmployeeContactFieldConstraints + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805092305_AddEmployeeContactFieldConstraints.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805092305_AddEmployeeContactFieldConstraints.cs new file mode 100644 index 0000000..a7ab7b9 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805092305_AddEmployeeContactFieldConstraints.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddEmployeeContactFieldConstraints : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "PhoneNumber", + table: "employees", + type: "character varying(50)", + maxLength: 50, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Email", + table: "employees", + type: "character varying(200)", + maxLength: 200, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Address", + table: "employees", + type: "character varying(200)", + maxLength: 200, + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "PhoneNumber", + table: "employees", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(50)", + oldMaxLength: 50, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Email", + table: "employees", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Address", + table: "employees", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(200)", + oldMaxLength: 200, + oldNullable: true); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805094410_AddAuditableSoftDelete.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805094410_AddAuditableSoftDelete.Designer.cs new file mode 100644 index 0000000..8607157 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805094410_AddAuditableSoftDelete.Designer.cs @@ -0,0 +1,617 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260805094410_AddAuditableSoftDelete")] + partial class AddAuditableSoftDelete + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805094410_AddAuditableSoftDelete.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805094410_AddAuditableSoftDelete.cs new file mode 100644 index 0000000..a2fd6ea --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260805094410_AddAuditableSoftDelete.cs @@ -0,0 +1,166 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAuditableSoftDelete : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "time_entries", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "time_entries", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "orders", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "orders", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "invoices", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "invoices", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "facilities", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "facilities", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "employees", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "employees", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DeletedAt", + table: "contracts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsDeleted", + table: "contracts", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "users"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "users"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "time_entries"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "time_entries"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "orders"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "orders"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "invoices"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "invoices"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "facilities"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "facilities"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "employees"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "employees"); + + migrationBuilder.DropColumn( + name: "DeletedAt", + table: "contracts"); + + migrationBuilder.DropColumn( + name: "IsDeleted", + table: "contracts"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806160932_AddPasswordResetCodes.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806160932_AddPasswordResetCodes.Designer.cs new file mode 100644 index 0000000..7c9b299 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806160932_AddPasswordResetCodes.Designer.cs @@ -0,0 +1,676 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260806160932_AddPasswordResetCodes")] + partial class AddPasswordResetCodes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ResetTokenUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ResetTokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_codes", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806160932_AddPasswordResetCodes.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806160932_AddPasswordResetCodes.cs new file mode 100644 index 0000000..9b1b5e9 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806160932_AddPasswordResetCodes.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddPasswordResetCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "password_reset_codes", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + CodeHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + AttemptCount = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + ConsumedAt = table.Column(type: "timestamp with time zone", nullable: true), + InvalidatedAt = table.Column(type: "timestamp with time zone", nullable: true), + ResetTokenHash = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + ResetTokenExpiresAt = table.Column(type: "timestamp with time zone", nullable: true), + ResetTokenUsedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_password_reset_codes", x => x.Id); + table.ForeignKey( + name: "FK_password_reset_codes_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_password_reset_codes_ResetTokenHash", + table: "password_reset_codes", + column: "ResetTokenHash"); + + migrationBuilder.CreateIndex( + name: "IX_password_reset_codes_UserId", + table: "password_reset_codes", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "password_reset_codes"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806175104_AddEmployeeStammdatenFields.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806175104_AddEmployeeStammdatenFields.Designer.cs new file mode 100644 index 0000000..0e16f3d --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806175104_AddEmployeeStammdatenFields.Designer.cs @@ -0,0 +1,696 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260806175104_AddEmployeeStammdatenFields")] + partial class AddEmployeeStammdatenFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmergencyContactRelation") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmploymentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Qualification") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ResetTokenUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ResetTokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_codes", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806175104_AddEmployeeStammdatenFields.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806175104_AddEmployeeStammdatenFields.cs new file mode 100644 index 0000000..1e1d449 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260806175104_AddEmployeeStammdatenFields.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddEmployeeStammdatenFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EmergencyContactName", + table: "employees", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "EmergencyContactPhone", + table: "employees", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "EmergencyContactRelation", + table: "employees", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "EmploymentType", + table: "employees", + type: "character varying(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "Qualification", + table: "employees", + type: "character varying(500)", + maxLength: 500, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EmergencyContactName", + table: "employees"); + + migrationBuilder.DropColumn( + name: "EmergencyContactPhone", + table: "employees"); + + migrationBuilder.DropColumn( + name: "EmergencyContactRelation", + table: "employees"); + + migrationBuilder.DropColumn( + name: "EmploymentType", + table: "employees"); + + migrationBuilder.DropColumn( + name: "Qualification", + table: "employees"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807071953_RenameEmployeeAddressToStructuredFields.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807071953_RenameEmployeeAddressToStructuredFields.Designer.cs new file mode 100644 index 0000000..d50d48d --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807071953_RenameEmployeeAddressToStructuredFields.Designer.cs @@ -0,0 +1,708 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260807071953_RenameEmployeeAddressToStructuredFields")] + partial class RenameEmployeeAddressToStructuredFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmergencyContactRelation") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmploymentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Qualification") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Street") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ResetTokenUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ResetTokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_codes", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807071953_RenameEmployeeAddressToStructuredFields.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807071953_RenameEmployeeAddressToStructuredFields.cs new file mode 100644 index 0000000..2242368 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807071953_RenameEmployeeAddressToStructuredFields.cs @@ -0,0 +1,61 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class RenameEmployeeAddressToStructuredFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Address", + table: "employees", + newName: "Street"); + + migrationBuilder.AddColumn( + name: "City", + table: "employees", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "Country", + table: "employees", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "PostalCode", + table: "employees", + type: "character varying(10)", + maxLength: 10, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "City", + table: "employees"); + + migrationBuilder.DropColumn( + name: "Country", + table: "employees"); + + migrationBuilder.DropColumn( + name: "PostalCode", + table: "employees"); + + migrationBuilder.RenameColumn( + name: "Street", + table: "employees", + newName: "Address"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807092137_AddUserMustChangePassword.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807092137_AddUserMustChangePassword.Designer.cs new file mode 100644 index 0000000..f1fdb96 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807092137_AddUserMustChangePassword.Designer.cs @@ -0,0 +1,711 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260807092137_AddUserMustChangePassword")] + partial class AddUserMustChangePassword + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmergencyContactRelation") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmploymentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Qualification") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Street") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ResetTokenUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ResetTokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_codes", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807092137_AddUserMustChangePassword.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807092137_AddUserMustChangePassword.cs new file mode 100644 index 0000000..349c5d5 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807092137_AddUserMustChangePassword.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddUserMustChangePassword : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MustChangePassword", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MustChangePassword", + table: "users"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807101715_AddLoginAttemptsAndEmployeeAvatar.Designer.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807101715_AddLoginAttemptsAndEmployeeAvatar.Designer.cs new file mode 100644 index 0000000..e648525 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807101715_AddLoginAttemptsAndEmployeeAvatar.Designer.cs @@ -0,0 +1,743 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + [Migration("20260807101715_AddLoginAttemptsAndEmployeeAvatar")] + partial class AddLoginAttemptsAndEmployeeAvatar + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarFileName") + .HasColumnType("text"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmergencyContactRelation") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmploymentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Qualification") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Street") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.LoginAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Succeeded") + .HasColumnType("boolean"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("IpAddress", "AttemptedAt"); + + b.ToTable("login_attempts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ResetTokenUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ResetTokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_codes", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807101715_AddLoginAttemptsAndEmployeeAvatar.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807101715_AddLoginAttemptsAndEmployeeAvatar.cs new file mode 100644 index 0000000..b474263 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/20260807101715_AddLoginAttemptsAndEmployeeAvatar.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddLoginAttemptsAndEmployeeAvatar : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvatarFileName", + table: "employees", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "login_attempts", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + IpAddress = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Username = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Succeeded = table.Column(type: "boolean", nullable: false), + AttemptedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_login_attempts", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_login_attempts_IpAddress_AttemptedAt", + table: "login_attempts", + columns: new[] { "IpAddress", "AttemptedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "login_attempts"); + + migrationBuilder.DropColumn( + name: "AvatarFileName", + table: "employees"); + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/OmsorgCoreDbContextModelSnapshot.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/OmsorgCoreDbContextModelSnapshot.cs new file mode 100644 index 0000000..a10997e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/Migrations/OmsorgCoreDbContextModelSnapshot.cs @@ -0,0 +1,740 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OmsorgCore.Infrastructure.Persistence; + +#nullable disable + +namespace OmsorgCore.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(OmsorgCoreDbContext))] + partial class OmsorgCoreDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContractType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("FacilityId"); + + b.ToTable("contracts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarFileName") + .HasColumnType("text"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EmergencyContactPhone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EmergencyContactRelation") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmploymentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("EntryDate") + .HasColumnType("date"); + + b.Property("ExitDate") + .HasColumnType("date"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PhoneNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PostalCode") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("Qualification") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Street") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("employees", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("BillingAddress") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CrmStatus") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("FacilityType") + .HasColumnType("text"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("facilities", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BillingPeriodEnd") + .HasColumnType("date"); + + b.Property("BillingPeriodStart") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DueDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("GrossAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("InvoiceNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("InvoiceNumber") + .IsUnique(); + + b.ToTable("invoices", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.LoginAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Succeeded") + .HasColumnType("boolean"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("IpAddress", "AttemptedAt"); + + b.ToTable("login_attempts", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("FacilityId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("RequiredQualification") + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.ToTable("orders", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttemptCount") + .HasColumnType("integer"); + + b.Property("CodeHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InvalidatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ResetTokenHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ResetTokenUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ResetTokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("password_reset_codes", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenId") + .HasColumnType("uuid"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedByTokenId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module", "Action") + .IsUnique(); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BreakDuration") + .HasColumnType("interval"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("End") + .HasColumnType("time without time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Start") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("OrderId"); + + b.ToTable("time_entries", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmployeeId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MustChangePassword") + .HasColumnType("boolean"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("SecurityStamp") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Action") + .HasColumnType("integer"); + + b.Property("Effect") + .HasColumnType("integer"); + + b.Property("Module") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Module", "Action") + .IsUnique(); + + b.ToTable("user_permission_overrides", (string)null); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Contract", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Employee"); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Invoice", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Order", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.PasswordResetCode", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RefreshToken", b => + { + b.HasOne("OmsorgCore.Domain.Entities.RefreshToken", null) + .WithMany() + .HasForeignKey("ReplacedByTokenId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.RolePermission", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.TimeEntry", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("OmsorgCore.Domain.Entities.Order", "Order") + .WithMany() + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.HasOne("OmsorgCore.Domain.Entities.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OmsorgCore.Domain.Entities.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.UserPermissionOverride", b => + { + b.HasOne("OmsorgCore.Domain.Entities.User", "User") + .WithMany("PermissionOverrides") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("Users"); + }); + + modelBuilder.Entity("OmsorgCore.Domain.Entities.User", b => + { + b.Navigation("PermissionOverrides"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/OmsorgCoreDbContext.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/OmsorgCoreDbContext.cs new file mode 100644 index 0000000..f1eab45 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Persistence/OmsorgCoreDbContext.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Persistence; + +public class OmsorgCoreDbContext : DbContext +{ + public OmsorgCoreDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Employees => Set(); + public DbSet Facilities => Set(); + public DbSet Contracts => Set(); + public DbSet Orders => Set(); + public DbSet TimeEntries => Set(); + public DbSet Invoices => Set(); + + public DbSet Users => Set(); + public DbSet Roles => Set(); + public DbSet RolePermissions => Set(); + public DbSet UserPermissionOverrides => Set(); + public DbSet RefreshTokens => Set(); + public DbSet PasswordResetCodes => Set(); + public DbSet LoginAttempts => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.ApplyConfigurationsFromAssembly(typeof(OmsorgCoreDbContext).Assembly); + base.OnModelCreating(modelBuilder); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/EmployeeRepository.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/EmployeeRepository.cs new file mode 100644 index 0000000..42b05d0 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/EmployeeRepository.cs @@ -0,0 +1,75 @@ +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Infrastructure.Persistence; + +namespace OmsorgCore.Infrastructure.Repositories; + +public class EmployeeRepository : IEmployeeRepository +{ + private readonly OmsorgCoreDbContext _db; + + public EmployeeRepository(OmsorgCoreDbContext db) + { + _db = db; + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _db.Employees.FirstOrDefaultAsync(e => e.Id == id, cancellationToken); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + => await _db.Employees.AsNoTracking().ToListAsync(cancellationToken); + + public async Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + string? search, + string? status, + string? employmentType, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + var query = _db.Employees.AsNoTracking().Where(e => !e.IsDeleted); + + if (!string.IsNullOrWhiteSpace(search)) + { + var pattern = $"%{search.Trim()}%"; + query = query.Where(e => + EF.Functions.ILike(e.FirstName, pattern) || + EF.Functions.ILike(e.LastName, pattern) || + (e.Email != null && EF.Functions.ILike(e.Email, pattern))); + } + + if (!string.IsNullOrWhiteSpace(status)) + { + query = query.Where(e => e.Status == status); + } + + if (!string.IsNullOrWhiteSpace(employmentType)) + { + query = query.Where(e => e.EmploymentType == employmentType); + } + + var totalCount = await query.CountAsync(cancellationToken); + + var items = await query + .OrderBy(e => e.LastName) + .ThenBy(e => e.FirstName) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(cancellationToken); + + return (items, totalCount); + } + + public async Task AddAsync(Employee employee, CancellationToken cancellationToken = default) + => await _db.Employees.AddAsync(employee, cancellationToken); + + public Task UpdateAsync(Employee employee, CancellationToken cancellationToken = default) + { + _db.Employees.Update(employee); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _db.SaveChangesAsync(cancellationToken); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/LoginAttemptRepository.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/LoginAttemptRepository.cs new file mode 100644 index 0000000..a229129 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/LoginAttemptRepository.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Infrastructure.Persistence; + +namespace OmsorgCore.Infrastructure.Repositories; + +public class LoginAttemptRepository : ILoginAttemptRepository +{ + private readonly OmsorgCoreDbContext _db; + + public LoginAttemptRepository(OmsorgCoreDbContext db) + { + _db = db; + } + + public Task CountRecentFailuresAsync(string ipAddress, DateTime since, CancellationToken cancellationToken = default) + => _db.LoginAttempts.CountAsync( + a => a.IpAddress == ipAddress && !a.Succeeded && a.AttemptedAt > since, cancellationToken); + + public async Task AddAsync(LoginAttempt attempt, CancellationToken cancellationToken = default) + => await _db.LoginAttempts.AddAsync(attempt, cancellationToken); + + public async Task ClearFailuresAsync(string ipAddress, CancellationToken cancellationToken = default) + { + var failures = await _db.LoginAttempts + .Where(a => a.IpAddress == ipAddress && !a.Succeeded) + .ToListAsync(cancellationToken); + _db.LoginAttempts.RemoveRange(failures); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _db.SaveChangesAsync(cancellationToken); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/PasswordResetCodeRepository.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/PasswordResetCodeRepository.cs new file mode 100644 index 0000000..1d3dbe4 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/PasswordResetCodeRepository.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Infrastructure.Persistence; + +namespace OmsorgCore.Infrastructure.Repositories; + +public class PasswordResetCodeRepository : IPasswordResetCodeRepository +{ + private readonly OmsorgCoreDbContext _db; + + public PasswordResetCodeRepository(OmsorgCoreDbContext db) + { + _db = db; + } + + public async Task AddAsync(PasswordResetCode code, CancellationToken cancellationToken = default) + => await _db.PasswordResetCodes.AddAsync(code, cancellationToken); + + public Task GetActiveByUserIdAsync(Guid userId, int maxAttempts, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return _db.PasswordResetCodes + .Where(c => c.UserId == userId + && c.InvalidatedAt == null + && c.ConsumedAt == null + && c.ExpiresAt > now + && c.AttemptCount < maxAttempts) + .OrderByDescending(c => c.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + } + + public Task GetLatestByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + => _db.PasswordResetCodes + .Where(c => c.UserId == userId) + .OrderByDescending(c => c.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + + public Task GetByResetTokenHashAsync(string resetTokenHash, CancellationToken cancellationToken = default) + => _db.PasswordResetCodes + .Include(c => c.User) + .FirstOrDefaultAsync(c => c.ResetTokenHash == resetTokenHash, cancellationToken); + + public async Task InvalidateActiveForUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var activeCodes = await _db.PasswordResetCodes + .Where(c => c.UserId == userId + && c.InvalidatedAt == null + && c.ConsumedAt == null + && c.ExpiresAt > now) + .ToListAsync(cancellationToken); + + foreach (var code in activeCodes) + { + code.InvalidatedAt = now; + } + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _db.SaveChangesAsync(cancellationToken); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/RefreshTokenRepository.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/RefreshTokenRepository.cs new file mode 100644 index 0000000..e614d2e --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/RefreshTokenRepository.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Infrastructure.Persistence; + +namespace OmsorgCore.Infrastructure.Repositories; + +public class RefreshTokenRepository : IRefreshTokenRepository +{ + private readonly OmsorgCoreDbContext _db; + + public RefreshTokenRepository(OmsorgCoreDbContext db) + { + _db = db; + } + + public async Task AddAsync(RefreshToken token, CancellationToken cancellationToken = default) + => await _db.RefreshTokens.AddAsync(token, cancellationToken); + + public Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default) + => _db.RefreshTokens + .Include(t => t.User) + .ThenInclude(u => u.Role) + .FirstOrDefaultAsync(t => t.TokenHash == tokenHash, cancellationToken); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _db.RefreshTokens + .Include(t => t.User) + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + + public async Task> GetAllActiveAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _db.RefreshTokens + .Include(t => t.User) + .Where(t => t.RevokedAt == null && t.ExpiresAt > now) + .OrderByDescending(t => t.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + return await _db.RefreshTokens + .Include(t => t.User) + .Where(t => t.UserId == userId && t.RevokedAt == null && t.ExpiresAt > now) + .ToListAsync(cancellationToken); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _db.SaveChangesAsync(cancellationToken); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/RoleRepository.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/RoleRepository.cs new file mode 100644 index 0000000..c26f593 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/RoleRepository.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Infrastructure.Persistence; + +namespace OmsorgCore.Infrastructure.Repositories; + +public class RoleRepository : IRoleRepository +{ + private readonly OmsorgCoreDbContext _db; + + public RoleRepository(OmsorgCoreDbContext db) + { + _db = db; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + => await _db.Roles.OrderBy(r => r.Name).ToListAsync(cancellationToken); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _db.Roles.FirstOrDefaultAsync(r => r.Id == id, cancellationToken); + + public Task ExistsByNameAsync(string name, CancellationToken cancellationToken = default) + => _db.Roles.AnyAsync(r => r.Name == name, cancellationToken); + + public async Task AddAsync(Role role, CancellationToken cancellationToken = default) + => await _db.Roles.AddAsync(role, cancellationToken); + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _db.SaveChangesAsync(cancellationToken); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/UserRepository.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/UserRepository.cs new file mode 100644 index 0000000..c3555ac --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Repositories/UserRepository.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Infrastructure.Persistence; + +namespace OmsorgCore.Infrastructure.Repositories; + +public class UserRepository : IUserRepository +{ + private readonly OmsorgCoreDbContext _db; + + public UserRepository(OmsorgCoreDbContext db) + { + _db = db; + } + + public Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default) + => _db.Users + .Include(u => u.Role) + .Include(u => u.Employee) + .FirstOrDefaultAsync(u => u.Username == username, cancellationToken); + + public Task GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default) + => _db.Users + .Include(u => u.Role) + .ThenInclude(r => r.RolePermissions) + .Include(u => u.PermissionOverrides) + .Include(u => u.Employee) + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => _db.Users.FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + + public Task GetByIdWithEmployeeAsync(Guid id, CancellationToken cancellationToken = default) + => _db.Users + .Include(u => u.Role) + .Include(u => u.Employee) + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + + public async Task> GetAllAsync(CancellationToken cancellationToken = default) + => await _db.Users.Include(u => u.Role).ToListAsync(cancellationToken); + + public Task GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default) + => _db.Users.FirstOrDefaultAsync(u => u.EmployeeId == employeeId, cancellationToken); + + public Task ExistsByUsernameAsync(string username, CancellationToken cancellationToken = default) + => _db.Users.AnyAsync(u => u.Username == username, cancellationToken); + + public async Task AddAsync(User user, CancellationToken cancellationToken = default) + => await _db.Users.AddAsync(user, cancellationToken); + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => _db.SaveChangesAsync(cancellationToken); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/JwtOptions.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/JwtOptions.cs new file mode 100644 index 0000000..6103fab --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/JwtOptions.cs @@ -0,0 +1,16 @@ +namespace OmsorgCore.Infrastructure.Security; + +/// +/// Gebunden an den "Jwt"-Abschnitt in appsettings.json. Secret NIEMALS einchecken — +/// in appsettings.Development.json steht nur ein lokaler Platzhalter, echtes Secret kommt +/// aus einer Umgebungsvariable oder dotnet user-secrets. +/// +public class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Secret { get; set; } = string.Empty; + public string Issuer { get; set; } = "OmsorgCore"; + public string Audience { get; set; } = "OmsorgClients"; + public int ExpiryMinutes { get; set; } = 60; +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/JwtTokenGenerator.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/JwtTokenGenerator.cs new file mode 100644 index 0000000..3bf5f9a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/JwtTokenGenerator.cs @@ -0,0 +1,44 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Infrastructure.Security; + +public class JwtTokenGenerator : IJwtTokenGenerator +{ + private readonly JwtOptions _options; + + public JwtTokenGenerator(IOptions options) + { + _options = options.Value; + } + + public (string Token, DateTime ExpiresAt) GenerateToken(User user) + { + var claims = new[] + { + new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.Role, user.Role.Name), + new Claim("sst", user.SecurityStamp.ToString()), + new Claim("mcp", user.MustChangePassword.ToString()) + }; + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var expiresAt = DateTime.UtcNow.AddMinutes(_options.ExpiryMinutes); + + var token = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + expires: expiresAt, + signingCredentials: credentials); + + return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt); + } +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/LoginAttemptOptions.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/LoginAttemptOptions.cs new file mode 100644 index 0000000..e0de3ef --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/LoginAttemptOptions.cs @@ -0,0 +1,9 @@ +namespace OmsorgCore.Infrastructure.Security; + +public class LoginAttemptOptions +{ + public const string SectionName = "Auth"; + + public int MaxLoginFailures { get; set; } = 5; + public int LoginLockoutMinutes { get; set; } = 10; +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/LoginLockoutPolicy.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/LoginLockoutPolicy.cs new file mode 100644 index 0000000..58313b8 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/LoginLockoutPolicy.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.Options; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Infrastructure.Security; + +public class LoginLockoutPolicy : ILoginLockoutPolicy +{ + private readonly LoginAttemptOptions _options; + + public LoginLockoutPolicy(IOptions options) + { + _options = options.Value; + } + + public int MaxFailures => _options.MaxLoginFailures; + public TimeSpan LockoutWindow => TimeSpan.FromMinutes(_options.LoginLockoutMinutes); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordHasher.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordHasher.cs new file mode 100644 index 0000000..0da3cff --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordHasher.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Identity; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Infrastructure.Security; + +/// +/// Nutzt den bewährten ASP.NET Core Identity Password-Hasher (PBKDF2) statt eigener Krypto-Logik. +/// +public class PasswordHasher : IPasswordHasher +{ + private readonly PasswordHasher _inner = new(); + + public string Hash(string plainPassword) + => _inner.HashPassword(new object(), plainPassword); + + public bool Verify(string plainPassword, string hash) + => _inner.VerifyHashedPassword(new object(), hash, plainPassword) != PasswordVerificationResult.Failed; +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordResetCodeGenerator.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordResetCodeGenerator.cs new file mode 100644 index 0000000..26a650a --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordResetCodeGenerator.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Infrastructure.Security; + +public class PasswordResetCodeGenerator : IPasswordResetCodeGenerator +{ + private readonly PasswordResetOptions _options; + + public PasswordResetCodeGenerator(IOptions options) + { + _options = options.Value; + } + + public TimeSpan RequestCooldown => TimeSpan.FromSeconds(_options.RequestCooldownSeconds); + + public int MaxAttempts => _options.MaxAttempts; + + public (string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin() + => GeneratePin(TimeSpan.FromMinutes(_options.PinExpiryMinutes)); + + public (string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin(TimeSpan validity) + { + var rawPin = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); + var expiresAt = DateTime.UtcNow.Add(validity); + return (rawPin, HashPin(rawPin), expiresAt); + } + + public (string RawResetToken, string ResetTokenHash, DateTime ExpiresAt) GenerateResetToken() + { + var rawResetToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + var expiresAt = DateTime.UtcNow.AddMinutes(_options.ResetTokenExpiryMinutes); + return (rawResetToken, HashResetToken(rawResetToken), expiresAt); + } + + public string HashPin(string rawPin) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawPin))); + + public string HashResetToken(string rawResetToken) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawResetToken))); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordResetOptions.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordResetOptions.cs new file mode 100644 index 0000000..8df9818 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/PasswordResetOptions.cs @@ -0,0 +1,19 @@ +namespace OmsorgCore.Infrastructure.Security; + +/// +/// Gebunden an denselben "Email"-Config-Abschnitt wie OmsorgCore.Email/EmailOptions - beide lesen +/// aus derselben appsettings-Sektion, bleiben aber unabhängige Klassen in ihren jeweiligen Schichten +/// (analog wie Jwt/RefreshToken je eigene Options-Klassen sind), damit Infrastructure nicht auf das +/// Email-Projekt referenzieren muss. +/// +public class PasswordResetOptions +{ + public const string SectionName = "Email"; + + public int PinExpiryMinutes { get; set; } = 5; + public int MaxAttempts { get; set; } = 3; + public int ResetTokenExpiryMinutes { get; set; } = 10; + + /// Mindestabstand zwischen zwei Code-Anfragen desselben Users (minimaler Spam-Schutz). + public int RequestCooldownSeconds { get; set; } = 60; +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/RefreshTokenGenerator.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/RefreshTokenGenerator.cs new file mode 100644 index 0000000..0b66dd1 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/RefreshTokenGenerator.cs @@ -0,0 +1,25 @@ +using System.Security.Cryptography; +using Microsoft.Extensions.Options; +using OmsorgCore.Application.Abstractions; + +namespace OmsorgCore.Infrastructure.Security; + +public class RefreshTokenGenerator : IRefreshTokenGenerator +{ + private readonly RefreshTokenOptions _options; + + public RefreshTokenGenerator(IOptions options) + { + _options = options.Value; + } + + public (string RawToken, string TokenHash, DateTime ExpiresAt) GenerateToken() + { + var rawToken = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); + var expiresAt = DateTime.UtcNow.AddDays(_options.ExpiryDays); + return (rawToken, HashToken(rawToken), expiresAt); + } + + public string HashToken(string rawToken) + => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rawToken))); +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/RefreshTokenOptions.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/RefreshTokenOptions.cs new file mode 100644 index 0000000..621d60f --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/RefreshTokenOptions.cs @@ -0,0 +1,13 @@ +namespace OmsorgCore.Infrastructure.Security; + +/// +/// Gebunden an den "RefreshToken"-Abschnitt in appsettings.json. Bewusst als eigene, +/// per Config/Umgebungsvariable (RefreshToken__ExpiryDays) änderbare Einstellung, damit +/// die Session-Dauer ohne Code-Änderung angepasst werden kann. +/// +public class RefreshTokenOptions +{ + public const string SectionName = "RefreshToken"; + + public int ExpiryDays { get; set; } = 60; +} diff --git a/omsorgCore/src/OmsorgCore.Infrastructure/Security/SeedOptions.cs b/omsorgCore/src/OmsorgCore.Infrastructure/Security/SeedOptions.cs new file mode 100644 index 0000000..0d67068 --- /dev/null +++ b/omsorgCore/src/OmsorgCore.Infrastructure/Security/SeedOptions.cs @@ -0,0 +1,14 @@ +namespace OmsorgCore.Infrastructure.Security; + +/// +/// Zugangsdaten für den Standard-Admin-Seed (), der nur im +/// Development-Modus läuft (siehe Program.cs). Konfigurierbar statt hardcodiert, damit kein festes +/// Passwort im Quellcode steht. +/// +public class SeedOptions +{ + public const string SectionName = "Seed"; + + public string AdminUsername { get; set; } = "admin"; + public string AdminPassword { get; set; } = "abersicher"; +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/GlobalUsings.cs b/omsorgCore/tests/OmsorgCore.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/omsorgCore/tests/OmsorgCore.Tests/OmsorgCore.Tests.csproj b/omsorgCore/tests/OmsorgCore.Tests/OmsorgCore.Tests.csproj new file mode 100644 index 0000000..598cd47 --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/OmsorgCore.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/omsorgCore/tests/OmsorgCore.Tests/Services/AuthServiceTests.cs b/omsorgCore/tests/OmsorgCore.Tests/Services/AuthServiceTests.cs new file mode 100644 index 0000000..5d99c13 --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/Services/AuthServiceTests.cs @@ -0,0 +1,214 @@ +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Tests.TestDoubles; + +namespace OmsorgCore.Tests.Services; + +public class AuthServiceTests +{ + private static User CreateUser(string username = "sabina", string passwordHash = "hashed:secret") => new() + { + Username = username, + PasswordHash = passwordHash, + IsActive = true, + RoleId = Guid.NewGuid(), + Role = new Role { Name = "Geschaeftsfuehrung" } + }; + + private const string TestIp = "127.0.0.1"; + + private static AuthService CreateSut( + FakeUserRepository? users = null, + FakeRefreshTokenRepository? refreshTokens = null, + FakeLoginAttemptRepository? loginAttempts = null, + FakeLoginLockoutPolicy? lockoutPolicy = null) + { + var userRepository = users ?? new FakeUserRepository(); + return new AuthService( + userRepository, + refreshTokens ?? new FakeRefreshTokenRepository(), + new FakePasswordHasher(), + new FakeJwtTokenGenerator(), + new FakeRefreshTokenGenerator(), + new PermissionService(userRepository), + loginAttempts ?? new FakeLoginAttemptRepository(), + lockoutPolicy ?? new FakeLoginLockoutPolicy()); + } + + [Fact] + public async Task LoginAsync_WithValidCredentials_ReturnsAccessAndRefreshToken() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var sut = CreateSut(users); + + var result = await sut.LoginAsync(user.Username, "secret", TestIp); + + Assert.True(result.Success); + Assert.False(string.IsNullOrEmpty(result.Token)); + Assert.False(string.IsNullOrEmpty(result.RefreshToken)); + Assert.NotNull(result.ExpiresAt); + } + + [Fact] + public async Task LoginAsync_WithWrongPassword_Fails() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var sut = CreateSut(users); + + var result = await sut.LoginAsync(user.Username, "wrong-password", TestIp); + + Assert.False(result.Success); + Assert.Null(result.RefreshToken); + } + + [Fact] + public async Task RefreshAsync_WithValidToken_RotatesAndIssuesNewPair() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var refreshTokens = new FakeRefreshTokenRepository(); + var sut = CreateSut(users, refreshTokens); + + var login = await sut.LoginAsync(user.Username, "secret", TestIp); + var refreshed = await sut.RefreshAsync(login.RefreshToken!); + + Assert.True(refreshed.Success); + Assert.NotEqual(login.RefreshToken, refreshed.RefreshToken); + Assert.NotEqual(login.Token, refreshed.Token); + + var oldTokenHash = new FakeRefreshTokenGenerator().HashToken(login.RefreshToken!); + var oldToken = await refreshTokens.GetByTokenHashAsync(oldTokenHash); + Assert.NotNull(oldToken!.RevokedAt); + Assert.NotNull(oldToken.ReplacedByTokenId); + } + + [Fact] + public async Task RefreshAsync_WithRevokedToken_Fails() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var refreshTokens = new FakeRefreshTokenRepository(); + var sut = CreateSut(users, refreshTokens); + + var login = await sut.LoginAsync(user.Username, "secret", TestIp); + await sut.RevokeAsync(login.RefreshToken!); + + var refreshed = await sut.RefreshAsync(login.RefreshToken!); + + Assert.False(refreshed.Success); + } + + [Fact] + public async Task RefreshAsync_WithExpiredToken_Fails() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var refreshTokens = new FakeRefreshTokenRepository(); + var sut = CreateSut(users, refreshTokens); + + var login = await sut.LoginAsync(user.Username, "secret", TestIp); + var tokenHash = new FakeRefreshTokenGenerator().HashToken(login.RefreshToken!); + var stored = await refreshTokens.GetByTokenHashAsync(tokenHash); + stored!.ExpiresAt = DateTime.UtcNow.AddMinutes(-1); + + var refreshed = await sut.RefreshAsync(login.RefreshToken!); + + Assert.False(refreshed.Success); + } + + [Fact] + public async Task RefreshAsync_WithUnknownToken_Fails() + { + var sut = CreateSut(); + + var refreshed = await sut.RefreshAsync("does-not-exist"); + + Assert.False(refreshed.Success); + } + + [Fact] + public async Task LoginAsync_RevokesPreviousActiveSessions() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var refreshTokens = new FakeRefreshTokenRepository(); + var sut = CreateSut(users, refreshTokens); + + var firstLogin = await sut.LoginAsync(user.Username, "secret", TestIp); + var secondLogin = await sut.LoginAsync(user.Username, "secret", TestIp); + + var firstTokenRefreshed = await sut.RefreshAsync(firstLogin.RefreshToken!); + Assert.False(firstTokenRefreshed.Success); + + var secondTokenRefreshed = await sut.RefreshAsync(secondLogin.RefreshToken!); + Assert.True(secondTokenRefreshed.Success); + } + + [Fact] + public async Task RevokeAsync_IsIdempotent() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var sut = CreateSut(users); + + var login = await sut.LoginAsync(user.Username, "secret", TestIp); + + await sut.RevokeAsync(login.RefreshToken!); + await sut.RevokeAsync(login.RefreshToken!); + + var refreshed = await sut.RefreshAsync(login.RefreshToken!); + Assert.False(refreshed.Success); + } + + [Fact] + public async Task LoginAsync_AfterMaxFailures_LocksOutEvenWithCorrectPassword() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var lockoutPolicy = new FakeLoginLockoutPolicy { MaxFailures = 3 }; + var sut = CreateSut(users, lockoutPolicy: lockoutPolicy); + + for (var i = 0; i < 3; i++) + { + await sut.LoginAsync(user.Username, "wrong-password", TestIp); + } + + var result = await sut.LoginAsync(user.Username, "secret", TestIp); + + Assert.False(result.Success); + Assert.True(result.IsLockedOut); + } + + [Fact] + public async Task LoginAsync_SuccessfulLogin_ClearsPriorFailures() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var lockoutPolicy = new FakeLoginLockoutPolicy { MaxFailures = 2 }; + var sut = CreateSut(users, lockoutPolicy: lockoutPolicy); + + await sut.LoginAsync(user.Username, "wrong-password", TestIp); + var successfulLogin = await sut.LoginAsync(user.Username, "secret", TestIp); + Assert.True(successfulLogin.Success); + + var nextLogin = await sut.LoginAsync(user.Username, "secret", TestIp); + Assert.True(nextLogin.Success); + Assert.False(nextLogin.IsLockedOut); + } + + [Fact] + public async Task LoginAsync_DifferentIp_IsNotAffectedByOtherIpsFailures() + { + var user = CreateUser(); + var users = new FakeUserRepository(user); + var lockoutPolicy = new FakeLoginLockoutPolicy { MaxFailures = 1 }; + var sut = CreateSut(users, lockoutPolicy: lockoutPolicy); + + await sut.LoginAsync(user.Username, "wrong-password", "10.0.0.1"); + var result = await sut.LoginAsync(user.Username, "secret", "10.0.0.2"); + + Assert.True(result.Success); + } +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/Services/EmailTemplateRendererTests.cs b/omsorgCore/tests/OmsorgCore.Tests/Services/EmailTemplateRendererTests.cs new file mode 100644 index 0000000..29b37f7 --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/Services/EmailTemplateRendererTests.cs @@ -0,0 +1,40 @@ +using OmsorgCore.Email; + +namespace OmsorgCore.Tests.Services; + +public class EmailTemplateRendererTests +{ + [Fact] + public void Render_ReplacesKnownPlaceholders() + { + var result = EmailTemplateRenderer.Render( + "Code: $RESET_PIN$, gültig $RESET_PIN_EXPIRY_MINUTES$ Minuten.", + new Dictionary + { + ["RESET_PIN"] = "123456", + ["RESET_PIN_EXPIRY_MINUTES"] = "5" + }); + + Assert.Equal("Code: 123456, gültig 5 Minuten.", result); + } + + [Fact] + public void Render_LeavesUnknownPlaceholdersUntouched() + { + var result = EmailTemplateRenderer.Render( + "Hallo $UNKNOWN$, Code: $RESET_PIN$.", + new Dictionary { ["RESET_PIN"] = "123456" }); + + Assert.Equal("Hallo $UNKNOWN$, Code: 123456.", result); + } + + [Fact] + public void Render_ReplacesRepeatedPlaceholder() + { + var result = EmailTemplateRenderer.Render( + "$RESET_PIN$ - $RESET_PIN$", + new Dictionary { ["RESET_PIN"] = "111" }); + + Assert.Equal("111 - 111", result); + } +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/Services/EmployeeServiceTests.cs b/omsorgCore/tests/OmsorgCore.Tests/Services/EmployeeServiceTests.cs new file mode 100644 index 0000000..f061ab8 --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/Services/EmployeeServiceTests.cs @@ -0,0 +1,177 @@ +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Domain.Enums; +using OmsorgCore.Tests.TestDoubles; + +namespace OmsorgCore.Tests.Services; + +public class EmployeeServiceTests +{ + [Fact] + public async Task CreateAsync_PersistsAllStammdatenFields() + { + var repository = new FakeEmployeeRepository(); + var sut = new EmployeeService(repository); + + var employee = new Employee + { + FirstName = "Sabrina", + LastName = "Berggötz", + DateOfBirth = new DateOnly(1990, 5, 1), + Street = "Musterstraße 1", + PostalCode = "12345", + City = "Musterstadt", + Country = "Deutschland", + PhoneNumber = "0170 1234567", + Email = "sabrina@omsorg.de", + EntryDate = new DateOnly(2024, 1, 1), + ExitDate = null, + EmergencyContactName = "Markus Berggötz", + EmergencyContactPhone = "0170 7654321", + EmergencyContactRelation = "Ehepartner", + EmploymentType = "Vollzeit", + Qualification = "Examinierte Pflegefachkraft" + }; + + var created = await sut.CreateAsync(employee); + var stored = await sut.GetByIdAsync(created.Id); + + Assert.NotNull(stored); + Assert.Equal("Sabrina", stored!.FirstName); + Assert.Equal("Berggötz", stored.LastName); + Assert.Equal(new DateOnly(1990, 5, 1), stored.DateOfBirth); + Assert.Equal("Musterstraße 1", stored.Street); + Assert.Equal("12345", stored.PostalCode); + Assert.Equal("Musterstadt", stored.City); + Assert.Equal("Deutschland", stored.Country); + Assert.Equal("0170 1234567", stored.PhoneNumber); + Assert.Equal("sabrina@omsorg.de", stored.Email); + Assert.Equal(new DateOnly(2024, 1, 1), stored.EntryDate); + Assert.Null(stored.ExitDate); + Assert.Equal("Aktiv", stored.Status); + Assert.Equal("Markus Berggötz", stored.EmergencyContactName); + Assert.Equal("0170 7654321", stored.EmergencyContactPhone); + Assert.Equal("Ehepartner", stored.EmergencyContactRelation); + Assert.Equal("Vollzeit", stored.EmploymentType); + Assert.Equal("Examinierte Pflegefachkraft", stored.Qualification); + } + + [Fact] + public async Task GetAllAsync_ReturnsCreatedEmployees() + { + var repository = new FakeEmployeeRepository(); + var sut = new EmployeeService(repository); + + await sut.CreateAsync(new Employee { FirstName = "Sascha", LastName = "Recruiting" }); + + var all = await sut.GetAllAsync(); + + Assert.Single(all); + } + + [Fact] + public async Task UpdateAsync_UpdatesAllMutableFields_AndReturnsUpdatedEmployee() + { + var repository = new FakeEmployeeRepository(); + var sut = new EmployeeService(repository); + var created = await sut.CreateAsync(new Employee { FirstName = "Sabrina", LastName = "Berggötz" }); + + var updates = new Employee + { + FirstName = "Sabrina", + LastName = "Kowalski", + Status = "Inaktiv", + DateOfBirth = new DateOnly(1990, 5, 1), + Street = "Neue Straße 2", + PostalCode = "54321", + City = "Neustadt", + Country = "Deutschland", + PhoneNumber = "0171 1111111", + Email = "sabrina.kowalski@omsorg.de", + EntryDate = new DateOnly(2024, 1, 1), + ExitDate = new DateOnly(2024, 12, 31), + EmergencyContactName = "Markus Berggötz", + EmergencyContactPhone = "0170 7654321", + EmergencyContactRelation = "Ehepartner", + EmploymentType = "Teilzeit", + Qualification = "Pflegehelferin" + }; + + var updated = await sut.UpdateAsync(created.Id, updates); + + Assert.NotNull(updated); + Assert.Equal("Kowalski", updated!.LastName); + Assert.Equal("Inaktiv", updated.Status); + Assert.Equal("Neue Straße 2", updated.Street); + Assert.Equal("54321", updated.PostalCode); + Assert.Equal("Neustadt", updated.City); + Assert.Equal("Teilzeit", updated.EmploymentType); + Assert.Equal("Pflegehelferin", updated.Qualification); + Assert.Equal("Markus Berggötz", updated.EmergencyContactName); + Assert.NotNull(updated.UpdatedAt); + } + + [Fact] + public async Task UpdateAsync_UnknownId_ReturnsNull() + { + var repository = new FakeEmployeeRepository(); + var sut = new EmployeeService(repository); + + var updated = await sut.UpdateAsync(Guid.NewGuid(), new Employee { FirstName = "X", LastName = "Y" }); + + Assert.Null(updated); + } + + [Fact] + public async Task GetPagedAsync_FiltersBySearchStatusAndEmploymentType_AndPaginates() + { + var repository = new FakeEmployeeRepository(); + var sut = new EmployeeService(repository); + + await sut.CreateAsync(new Employee { FirstName = "Anna", LastName = "Beispiel", Status = "Aktiv", EmploymentType = "Vollzeit" }); + await sut.CreateAsync(new Employee { FirstName = "Bernd", LastName = "Muster", Status = "Aktiv", EmploymentType = "Teilzeit" }); + await sut.CreateAsync(new Employee { FirstName = "Carla", LastName = "Beispiel", Status = "Urlaub", EmploymentType = "Vollzeit" }); + + var (items, totalCount) = await sut.GetPagedAsync(search: "beispiel", status: "Aktiv", employmentType: "Vollzeit", page: 1, pageSize: 10); + + Assert.Equal(1, totalCount); + Assert.Single(items); + Assert.Equal("Anna", items[0].FirstName); + } + + [Fact] + public async Task GetPagedAsync_PaginatesResults() + { + var repository = new FakeEmployeeRepository(); + var sut = new EmployeeService(repository); + + await sut.CreateAsync(new Employee { FirstName = "Anna", LastName = "Eins" }); + await sut.CreateAsync(new Employee { FirstName = "Bernd", LastName = "Zwei" }); + await sut.CreateAsync(new Employee { FirstName = "Carla", LastName = "Drei" }); + + var (items, totalCount) = await sut.GetPagedAsync(search: null, status: null, employmentType: null, page: 2, pageSize: 2); + + Assert.Equal(3, totalCount); + Assert.Single(items); + } + + [Fact] + public async Task HasPermissionAsync_WithoutEmployeesCreatePermission_ReturnsFalse() + { + var role = new Role { Name = "Recruiting" }; + var user = new User + { + Username = "sascha", + PasswordHash = "hashed:secret", + IsActive = true, + RoleId = role.Id, + Role = role + }; + + var permissionService = new PermissionService(new FakeUserRepository(user)); + + var allowed = await permissionService.HasPermissionAsync(user.Id, ModuleType.Employees, PermissionAction.Create); + + Assert.False(allowed); + } +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/Services/PasswordResetServiceTests.cs b/omsorgCore/tests/OmsorgCore.Tests/Services/PasswordResetServiceTests.cs new file mode 100644 index 0000000..40b9b17 --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/Services/PasswordResetServiceTests.cs @@ -0,0 +1,215 @@ +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Tests.TestDoubles; + +namespace OmsorgCore.Tests.Services; + +public class PasswordResetServiceTests +{ + private static User CreateUserWithEmployee(string username = "pflegekraft", string email = "pflegekraft@example.com") => new() + { + Username = username, + PasswordHash = "hashed:secret", + IsActive = true, + RoleId = Guid.NewGuid(), + Role = new Role { Name = "Aussendienst" }, + EmployeeId = Guid.NewGuid(), + Employee = new Employee { Email = email } + }; + + private static PasswordResetService CreateSut( + FakeUserRepository users, + FakePasswordResetCodeRepository? codes = null, + FakePasswordResetCodeGenerator? generator = null, + FakeRefreshTokenRepository? refreshTokens = null) + { + return new PasswordResetService( + users, + codes ?? new FakePasswordResetCodeRepository(), + generator ?? new FakePasswordResetCodeGenerator(), + new FakePasswordHasher(), + refreshTokens ?? new FakeRefreshTokenRepository()); + } + + [Fact] + public async Task RequestResetAsync_UnknownUsername_ReturnsCannotReset() + { + var sut = CreateSut(new FakeUserRepository()); + + var result = await sut.RequestResetAsync("does-not-exist"); + + Assert.Equal(PasswordResetRequestStatus.CannotReset, result.Status); + } + + [Fact] + public async Task RequestResetAsync_UserWithoutEmployeeLink_ReturnsCannotReset() + { + var user = new User + { + Username = "admin", + PasswordHash = "hashed:secret", + IsActive = true, + RoleId = Guid.NewGuid(), + Role = new Role { Name = "Administrator" } + }; + var sut = CreateSut(new FakeUserRepository(user)); + + var result = await sut.RequestResetAsync(user.Username); + + Assert.Equal(PasswordResetRequestStatus.CannotReset, result.Status); + } + + [Fact] + public async Task RequestResetAsync_UserWithEmployeeEmail_ReturnsSentWithPin() + { + var user = CreateUserWithEmployee(); + var sut = CreateSut(new FakeUserRepository(user)); + + var result = await sut.RequestResetAsync(user.Username); + + Assert.Equal(PasswordResetRequestStatus.Sent, result.Status); + Assert.Equal(user.Employee!.Email, result.Email); + Assert.False(string.IsNullOrEmpty(result.RawPin)); + } + + [Fact] + public async Task VerifyCodeAsync_WithCorrectPin_ReturnsResetToken() + { + var user = CreateUserWithEmployee(); + var codes = new FakePasswordResetCodeRepository(); + var sut = CreateSut(new FakeUserRepository(user), codes); + + var requested = await sut.RequestResetAsync(user.Username); + var verified = await sut.VerifyCodeAsync(user.Username, requested.RawPin!); + + Assert.True(verified.Success); + Assert.False(string.IsNullOrEmpty(verified.ResetToken)); + } + + [Fact] + public async Task VerifyCodeAsync_WithWrongPin_IncrementsAttemptCount() + { + var user = CreateUserWithEmployee(); + var codes = new FakePasswordResetCodeRepository(); + var sut = CreateSut(new FakeUserRepository(user), codes); + + await sut.RequestResetAsync(user.Username); + var verified = await sut.VerifyCodeAsync(user.Username, "000000"); + + Assert.False(verified.Success); + Assert.Equal("invalid_or_expired", verified.FailureReason); + } + + [Fact] + public async Task VerifyCodeAsync_ThirdWrongAttempt_InvalidatesCode() + { + var user = CreateUserWithEmployee(); + var codes = new FakePasswordResetCodeRepository(); + var sut = CreateSut(new FakeUserRepository(user), codes); + + await sut.RequestResetAsync(user.Username); + await sut.VerifyCodeAsync(user.Username, "000000"); + await sut.VerifyCodeAsync(user.Username, "000000"); + var thirdAttempt = await sut.VerifyCodeAsync(user.Username, "000000"); + + Assert.False(thirdAttempt.Success); + Assert.Equal("too_many_attempts", thirdAttempt.FailureReason); + + // Auch der eigentlich korrekte PIN wird jetzt nicht mehr akzeptiert. + var correctPinTooLate = await sut.VerifyCodeAsync(user.Username, "123456"); + Assert.False(correctPinTooLate.Success); + } + + [Fact] + public async Task VerifyCodeAsync_ExpiredCode_Fails() + { + var user = CreateUserWithEmployee(); + var codes = new FakePasswordResetCodeRepository(); + var sut = CreateSut(new FakeUserRepository(user), codes); + + var requested = await sut.RequestResetAsync(user.Username); + var stored = await codes.GetLatestByUserIdAsync(user.Id); + stored!.ExpiresAt = DateTime.UtcNow.AddMinutes(-1); + + var verified = await sut.VerifyCodeAsync(user.Username, requested.RawPin!); + + Assert.False(verified.Success); + Assert.Equal("invalid_or_expired", verified.FailureReason); + } + + [Fact] + public async Task ResetPasswordAsync_WithValidResetToken_ChangesPasswordAndRevokesSessions() + { + var user = CreateUserWithEmployee(); + var codes = new FakePasswordResetCodeRepository(); + var refreshTokens = new FakeRefreshTokenRepository(); + await refreshTokens.AddAsync(new RefreshToken + { + UserId = user.Id, + User = user, + TokenHash = "existing-session-hash", + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + var sut = CreateSut(new FakeUserRepository(user), codes, refreshTokens: refreshTokens); + + var requested = await sut.RequestResetAsync(user.Username); + var verified = await sut.VerifyCodeAsync(user.Username, requested.RawPin!); + var oldSecurityStamp = user.SecurityStamp; + + var success = await sut.ResetPasswordAsync(verified.ResetToken!, "neuesPasswort"); + + Assert.True(success); + Assert.Equal("hashed:neuesPasswort", user.PasswordHash); + Assert.NotEqual(oldSecurityStamp, user.SecurityStamp); + + var existingSession = await refreshTokens.GetByTokenHashAsync("existing-session-hash"); + Assert.NotNull(existingSession!.RevokedAt); + } + + [Fact] + public async Task ResetPasswordAsync_ResetTokenIsSingleUse() + { + var user = CreateUserWithEmployee(); + var codes = new FakePasswordResetCodeRepository(); + var sut = CreateSut(new FakeUserRepository(user), codes); + + var requested = await sut.RequestResetAsync(user.Username); + var verified = await sut.VerifyCodeAsync(user.Username, requested.RawPin!); + + var first = await sut.ResetPasswordAsync(verified.ResetToken!, "erstesPasswort"); + var second = await sut.ResetPasswordAsync(verified.ResetToken!, "zweitesPasswort"); + + Assert.True(first); + Assert.False(second); + } + + [Fact] + public async Task IssueInviteCodeAsync_ReturnsPin_WithRequestedValidity() + { + var user = CreateUserWithEmployee(); + var codes = new FakePasswordResetCodeRepository(); + var sut = CreateSut(new FakeUserRepository(user), codes); + + var rawPin = await sut.IssueInviteCodeAsync(user.Id, TimeSpan.FromDays(7)); + + Assert.False(string.IsNullOrEmpty(rawPin)); + var stored = await codes.GetLatestByUserIdAsync(user.Id); + Assert.NotNull(stored); + Assert.True(stored!.ExpiresAt > DateTime.UtcNow.AddDays(6)); + Assert.True(stored.ExpiresAt < DateTime.UtcNow.AddDays(8)); + } + + [Fact] + public async Task IssueInviteCodeAsync_ReIssuing_StillProducesAVerifiableCode() + { + var user = CreateUserWithEmployee(); + var codes = new FakePasswordResetCodeRepository(); + var sut = CreateSut(new FakeUserRepository(user), codes); + + await sut.IssueInviteCodeAsync(user.Id, TimeSpan.FromDays(7)); + var secondPin = await sut.IssueInviteCodeAsync(user.Id, TimeSpan.FromDays(7)); + + var verified = await sut.VerifyCodeAsync(user.Username, secondPin); + Assert.True(verified.Success); + } +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/Services/PermissionServiceTests.cs b/omsorgCore/tests/OmsorgCore.Tests/Services/PermissionServiceTests.cs new file mode 100644 index 0000000..6ec620a --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/Services/PermissionServiceTests.cs @@ -0,0 +1,96 @@ +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Domain.Enums; +using OmsorgCore.Tests.TestDoubles; + +namespace OmsorgCore.Tests.Services; + +public class PermissionServiceTests +{ + private static User CreateUser(Role role, params UserPermissionOverride[] overrides) + { + var user = new User + { + Username = "sabrina", + PasswordHash = "hashed:secret", + IsActive = true, + RoleId = role.Id, + Role = role + }; + + foreach (var override_ in overrides) + { + override_.UserId = user.Id; + override_.User = user; + user.PermissionOverrides.Add(override_); + } + + return user; + } + + [Fact] + public async Task GetGrantedPermissionsAsync_ReturnsRoleDefaults() + { + var role = new Role { Name = "Disposition" }; + role.RolePermissions.Add(new RolePermission { Role = role, RoleId = role.Id, Module = ModuleType.Orders, Action = PermissionAction.View }); + role.RolePermissions.Add(new RolePermission { Role = role, RoleId = role.Id, Module = ModuleType.Orders, Action = PermissionAction.Create }); + + var user = CreateUser(role); + var sut = new PermissionService(new FakeUserRepository(user)); + + var grants = await sut.GetGrantedPermissionsAsync(user.Id); + + Assert.Contains(grants, g => g.Module == ModuleType.Orders && g.Action == PermissionAction.View); + Assert.Contains(grants, g => g.Module == ModuleType.Orders && g.Action == PermissionAction.Create); + Assert.DoesNotContain(grants, g => g.Module == ModuleType.UserManagement); + } + + [Fact] + public async Task GetGrantedPermissionsAsync_OverrideRevokesRoleDefault() + { + var role = new Role { Name = "Disposition" }; + role.RolePermissions.Add(new RolePermission { Role = role, RoleId = role.Id, Module = ModuleType.Orders, Action = PermissionAction.Delete }); + + var user = CreateUser(role, new UserPermissionOverride + { + Module = ModuleType.Orders, + Action = PermissionAction.Delete, + Effect = PermissionEffect.Revoke + }); + var sut = new PermissionService(new FakeUserRepository(user)); + + var grants = await sut.GetGrantedPermissionsAsync(user.Id); + + Assert.DoesNotContain(grants, g => g.Module == ModuleType.Orders && g.Action == PermissionAction.Delete); + } + + [Fact] + public async Task GetGrantedPermissionsAsync_OverrideGrantsBeyondRoleDefault() + { + var role = new Role { Name = "Recruiting" }; + + var user = CreateUser(role, new UserPermissionOverride + { + Module = ModuleType.UserManagement, + Action = PermissionAction.View, + Effect = PermissionEffect.Grant + }); + var sut = new PermissionService(new FakeUserRepository(user)); + + var grants = await sut.GetGrantedPermissionsAsync(user.Id); + + Assert.Contains(grants, g => g.Module == ModuleType.UserManagement && g.Action == PermissionAction.View); + } + + [Fact] + public async Task HasPermissionAsync_WithoutUserManagementPermission_ReturnsFalse() + { + var role = new Role { Name = "Recruiting" }; + var user = CreateUser(role); + var sut = new PermissionService(new FakeUserRepository(user)); + + var allowed = await sut.HasPermissionAsync(user.Id, ModuleType.UserManagement, PermissionAction.View); + + Assert.False(allowed); + } +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/Services/RoleServiceTests.cs b/omsorgCore/tests/OmsorgCore.Tests/Services/RoleServiceTests.cs new file mode 100644 index 0000000..10aab0a --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/Services/RoleServiceTests.cs @@ -0,0 +1,45 @@ +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Tests.TestDoubles; + +namespace OmsorgCore.Tests.Services; + +public class RoleServiceTests +{ + [Fact] + public async Task CreateAsync_WithNewName_CreatesRole() + { + var roles = new FakeRoleRepository(); + var sut = new RoleService(roles); + + var result = await sut.CreateAsync("Aussendienst"); + + Assert.True(result.Success); + Assert.Equal("Aussendienst", result.Role!.Name); + var all = await roles.GetAllAsync(); + Assert.Single(all); + } + + [Fact] + public async Task CreateAsync_WithExistingName_Fails() + { + var roles = new FakeRoleRepository(new Role { Name = "Aussendienst" }); + var sut = new RoleService(roles); + + var result = await sut.CreateAsync("Aussendienst"); + + Assert.False(result.Success); + Assert.Equal(CreateRoleFailureReason.NameTaken, result.FailureReason); + } + + [Fact] + public async Task CreateAsync_WithEmptyName_Fails() + { + var sut = new RoleService(new FakeRoleRepository()); + + var result = await sut.CreateAsync(" "); + + Assert.False(result.Success); + Assert.Equal(CreateRoleFailureReason.NameRequired, result.FailureReason); + } +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/Services/SessionAdminServiceTests.cs b/omsorgCore/tests/OmsorgCore.Tests/Services/SessionAdminServiceTests.cs new file mode 100644 index 0000000..59fa252 --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/Services/SessionAdminServiceTests.cs @@ -0,0 +1,105 @@ +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Tests.TestDoubles; + +namespace OmsorgCore.Tests.Services; + +public class SessionAdminServiceTests +{ + private static User CreateUser(string username) => new() + { + Username = username, + PasswordHash = "hashed:secret", + IsActive = true, + RoleId = Guid.NewGuid(), + Role = new Role { Name = "Geschaeftsfuehrung" }, + SecurityStamp = Guid.NewGuid() + }; + + private static RefreshToken CreateActiveToken(User user, string tokenHash) => new() + { + UserId = user.Id, + User = user, + TokenHash = tokenHash, + ExpiresAt = DateTime.UtcNow.AddDays(30) + }; + + [Fact] + public async Task GetActiveSessionsAsync_ReturnsOnlyActiveTokens() + { + var user = CreateUser("sabina"); + var refreshTokens = new FakeRefreshTokenRepository(); + var activeToken = CreateActiveToken(user, "hash-active"); + await refreshTokens.AddAsync(activeToken); + + var sut = new SessionAdminService(refreshTokens, new FakeUserRepository(user)); + + var sessions = await sut.GetActiveSessionsAsync(); + + var session = Assert.Single(sessions); + Assert.Equal(activeToken.Id, session.Id); + Assert.Equal("sabina", session.Username); + } + + [Fact] + public async Task RevokeSessionAsync_RevokesTokenAndBumpsOnlyThatUsersStamp() + { + var userA = CreateUser("sabina"); + var userB = CreateUser("malik"); + var stampBeforeA = userA.SecurityStamp; + var stampBeforeB = userB.SecurityStamp; + + var refreshTokens = new FakeRefreshTokenRepository(); + var tokenA = CreateActiveToken(userA, "hash-a"); + await refreshTokens.AddAsync(tokenA); + var tokenB = CreateActiveToken(userB, "hash-b"); + await refreshTokens.AddAsync(tokenB); + + var sut = new SessionAdminService(refreshTokens, new FakeUserRepository(userA, userB)); + + await sut.RevokeSessionAsync(tokenA.Id); + + Assert.NotNull(tokenA.RevokedAt); + Assert.Null(tokenB.RevokedAt); + Assert.NotEqual(stampBeforeA, userA.SecurityStamp); + Assert.Equal(stampBeforeB, userB.SecurityStamp); + } + + [Fact] + public async Task RevokeSessionAsync_WithUnknownId_DoesNothing() + { + var sut = new SessionAdminService(new FakeRefreshTokenRepository(), new FakeUserRepository()); + + await sut.RevokeSessionAsync(Guid.NewGuid()); + + var sessions = await sut.GetActiveSessionsAsync(); + Assert.Empty(sessions); + } + + [Fact] + public async Task RevokeAllSessionsAsync_RevokesEveryTokenAndBumpsEveryUsersStamp() + { + var userA = CreateUser("sabina"); + var userB = CreateUser("malik"); + var stampBeforeA = userA.SecurityStamp; + var stampBeforeB = userB.SecurityStamp; + + var refreshTokens = new FakeRefreshTokenRepository(); + var tokenA = CreateActiveToken(userA, "hash-a"); + await refreshTokens.AddAsync(tokenA); + var tokenB = CreateActiveToken(userB, "hash-b"); + await refreshTokens.AddAsync(tokenB); + + var sut = new SessionAdminService(refreshTokens, new FakeUserRepository(userA, userB)); + + await sut.RevokeAllSessionsAsync(); + + Assert.NotNull(tokenA.RevokedAt); + Assert.NotNull(tokenB.RevokedAt); + Assert.NotEqual(stampBeforeA, userA.SecurityStamp); + Assert.NotEqual(stampBeforeB, userB.SecurityStamp); + + var sessions = await sut.GetActiveSessionsAsync(); + Assert.Empty(sessions); + } +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/Services/UserServiceTests.cs b/omsorgCore/tests/OmsorgCore.Tests/Services/UserServiceTests.cs new file mode 100644 index 0000000..d482e27 --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/Services/UserServiceTests.cs @@ -0,0 +1,342 @@ +using OmsorgCore.Application.Services; +using OmsorgCore.Domain.Entities; +using OmsorgCore.Tests.TestDoubles; + +namespace OmsorgCore.Tests.Services; + +public class UserServiceTests +{ + private static Employee CreateEmployee(string? email = "mitarbeiter@example.com") => new() + { + FirstName = "Test", + LastName = "Mitarbeiter", + Email = email + }; + + private static UserService CreateSut( + FakeUserRepository users, + FakeEmployeeRepository employees, + FakeRoleRepository roles, + FakePasswordResetCodeRepository? codes = null, + FakeRefreshTokenRepository? refreshTokens = null) + { + var actualRefreshTokens = refreshTokens ?? new FakeRefreshTokenRepository(); + var passwordResetService = new PasswordResetService( + users, codes ?? new FakePasswordResetCodeRepository(), + new FakePasswordResetCodeGenerator(), new FakePasswordHasher(), + actualRefreshTokens); + + return new UserService( + users, employees, roles, new FakePasswordHasher(), + passwordResetService, actualRefreshTokens); + } + + [Fact] + public async Task CreateForEmployeeAsync_InviteMode_CreatesUserWithoutMustChangePassword_AndIssuesPin() + { + var employee = CreateEmployee(); + var employees = new FakeEmployeeRepository(); + await employees.AddAsync(employee); + var role = new Role { Name = "Aussendienst" }; + var roles = new FakeRoleRepository(role); + var users = new FakeUserRepository(); + + var sut = CreateSut(users, employees, roles); + + var result = await sut.CreateForEmployeeAsync( + employee.Id, "neuer.user", role.Id, UserCreationMode.Invite, + initialPassword: null, invitePinValidity: TimeSpan.FromDays(7)); + + Assert.True(result.Success); + Assert.Equal(employee.Email, result.Email); + Assert.False(string.IsNullOrEmpty(result.RawInvitePin)); + + var created = await users.GetByIdAsync(result.UserId!.Value); + Assert.NotNull(created); + Assert.False(created!.MustChangePassword); + } + + [Fact] + public async Task CreateForEmployeeAsync_DirectMode_SetsPasswordAndMustChangePassword() + { + var employee = CreateEmployee(); + var employees = new FakeEmployeeRepository(); + await employees.AddAsync(employee); + var role = new Role { Name = "Disposition" }; + var roles = new FakeRoleRepository(role); + var users = new FakeUserRepository(); + + var sut = CreateSut(users, employees, roles); + + var result = await sut.CreateForEmployeeAsync( + employee.Id, "direkt.user", role.Id, UserCreationMode.Direct, + initialPassword: "initialesPasswort", invitePinValidity: null); + + Assert.True(result.Success); + Assert.Null(result.RawInvitePin); + + var created = await users.GetByIdAsync(result.UserId!.Value); + Assert.NotNull(created); + Assert.True(created!.MustChangePassword); + Assert.Equal("hashed:initialesPasswort", created.PasswordHash); + } + + [Fact] + public async Task CreateForEmployeeAsync_DirectMode_WithoutPassword_Fails() + { + var employee = CreateEmployee(); + var employees = new FakeEmployeeRepository(); + await employees.AddAsync(employee); + var role = new Role { Name = "Disposition" }; + var roles = new FakeRoleRepository(role); + var users = new FakeUserRepository(); + + var sut = CreateSut(users, employees, roles); + + var result = await sut.CreateForEmployeeAsync( + employee.Id, "direkt.user", role.Id, UserCreationMode.Direct, + initialPassword: null, invitePinValidity: null); + + Assert.False(result.Success); + Assert.Equal(CreateUserFailureReason.InitialPasswordRequired, result.FailureReason); + } + + [Fact] + public async Task CreateForEmployeeAsync_InviteMode_WithoutEmployeeEmail_Fails() + { + var employee = CreateEmployee(email: null); + var employees = new FakeEmployeeRepository(); + await employees.AddAsync(employee); + var role = new Role { Name = "Aussendienst" }; + var roles = new FakeRoleRepository(role); + var users = new FakeUserRepository(); + + var sut = CreateSut(users, employees, roles); + + var result = await sut.CreateForEmployeeAsync( + employee.Id, "neuer.user", role.Id, UserCreationMode.Invite, + initialPassword: null, invitePinValidity: TimeSpan.FromDays(7)); + + Assert.False(result.Success); + Assert.Equal(CreateUserFailureReason.EmployeeEmailMissing, result.FailureReason); + } + + [Fact] + public async Task CreateForEmployeeAsync_EmployeeAlreadyHasAccount_Fails() + { + var employee = CreateEmployee(); + var employees = new FakeEmployeeRepository(); + await employees.AddAsync(employee); + var role = new Role { Name = "Aussendienst" }; + var roles = new FakeRoleRepository(role); + var existingUser = new User + { + Username = "schon.da", + PasswordHash = "hashed:x", + RoleId = role.Id, + Role = role, + EmployeeId = employee.Id + }; + var users = new FakeUserRepository(existingUser); + + var sut = CreateSut(users, employees, roles); + + var result = await sut.CreateForEmployeeAsync( + employee.Id, "zweiter.user", role.Id, UserCreationMode.Direct, + initialPassword: "irgendeinPasswort", invitePinValidity: null); + + Assert.False(result.Success); + Assert.Equal(CreateUserFailureReason.EmployeeAlreadyHasAccount, result.FailureReason); + } + + [Fact] + public async Task CreateForEmployeeAsync_UsernameTaken_Fails() + { + var employee = CreateEmployee(); + var employees = new FakeEmployeeRepository(); + await employees.AddAsync(employee); + var role = new Role { Name = "Aussendienst" }; + var roles = new FakeRoleRepository(role); + var existingUser = new User + { + Username = "belegt", + PasswordHash = "hashed:x", + RoleId = role.Id, + Role = role + }; + var users = new FakeUserRepository(existingUser); + + var sut = CreateSut(users, employees, roles); + + var result = await sut.CreateForEmployeeAsync( + employee.Id, "belegt", role.Id, UserCreationMode.Direct, + initialPassword: "irgendeinPasswort", invitePinValidity: null); + + Assert.False(result.Success); + Assert.Equal(CreateUserFailureReason.UsernameTaken, result.FailureReason); + } + + [Fact] + public async Task ChangeOwnPasswordAsync_WithCorrectCurrentPassword_UpdatesPasswordAndClearsFlag() + { + var role = new Role { Name = "Disposition" }; + var user = new User + { + Username = "muss.wechseln", + PasswordHash = "hashed:altesPasswort", + RoleId = role.Id, + Role = role, + MustChangePassword = true + }; + var users = new FakeUserRepository(user); + var refreshTokens = new FakeRefreshTokenRepository(); + await refreshTokens.AddAsync(new RefreshToken + { + UserId = user.Id, + User = user, + TokenHash = "session-hash", + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + + var sut = CreateSut(users, new FakeEmployeeRepository(), new FakeRoleRepository(), refreshTokens: refreshTokens); + var oldStamp = user.SecurityStamp; + + var success = await sut.ChangeOwnPasswordAsync(user.Id, "altesPasswort", "neuesPasswort"); + + Assert.True(success); + Assert.False(user.MustChangePassword); + Assert.Equal("hashed:neuesPasswort", user.PasswordHash); + Assert.NotEqual(oldStamp, user.SecurityStamp); + + var session = await refreshTokens.GetByTokenHashAsync("session-hash"); + Assert.NotNull(session!.RevokedAt); + } + + [Fact] + public async Task AdminResetPasswordAsync_DirectMode_SetsPasswordAndMustChangePassword() + { + var role = new Role { Name = "Disposition" }; + var employee = CreateEmployee(); + var user = new User + { + Username = "bestehender.user", + PasswordHash = "hashed:altesPasswort", + RoleId = role.Id, + Role = role, + Employee = employee, + EmployeeId = employee.Id + }; + var users = new FakeUserRepository(user); + var sut = CreateSut(users, new FakeEmployeeRepository(), new FakeRoleRepository(role)); + + var result = await sut.AdminResetPasswordAsync(user.Id, UserCreationMode.Direct, "neuesInitialesPw", null); + + Assert.True(result.Success); + Assert.Equal("hashed:neuesInitialesPw", user.PasswordHash); + Assert.True(user.MustChangePassword); + } + + [Fact] + public async Task AdminResetPasswordAsync_InviteMode_IssuesPin() + { + var role = new Role { Name = "Disposition" }; + var employee = CreateEmployee(); + var user = new User + { + Username = "bestehender.user", + PasswordHash = "hashed:altesPasswort", + RoleId = role.Id, + Role = role, + Employee = employee, + EmployeeId = employee.Id + }; + var users = new FakeUserRepository(user); + var sut = CreateSut(users, new FakeEmployeeRepository(), new FakeRoleRepository(role)); + + var result = await sut.AdminResetPasswordAsync(user.Id, UserCreationMode.Invite, null, TimeSpan.FromDays(3)); + + Assert.True(result.Success); + Assert.Equal(employee.Email, result.Email); + Assert.False(string.IsNullOrEmpty(result.RawInvitePin)); + } + + [Fact] + public async Task AdminResetPasswordAsync_UnknownUser_Fails() + { + var sut = CreateSut(new FakeUserRepository(), new FakeEmployeeRepository(), new FakeRoleRepository()); + + var result = await sut.AdminResetPasswordAsync(Guid.NewGuid(), UserCreationMode.Direct, "irgendeinPasswort", null); + + Assert.False(result.Success); + Assert.Equal(AdminResetPasswordFailureReason.UserNotFound, result.FailureReason); + } + + [Fact] + public async Task UpdateAsync_DeactivatingUser_RevokesSessionsImmediately() + { + var role = new Role { Name = "Disposition" }; + var user = new User + { + Username = "aktiver.user", + PasswordHash = "hashed:x", + RoleId = role.Id, + Role = role, + IsActive = true + }; + var users = new FakeUserRepository(user); + var refreshTokens = new FakeRefreshTokenRepository(); + await refreshTokens.AddAsync(new RefreshToken + { + UserId = user.Id, + User = user, + TokenHash = "session-hash", + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + var sut = CreateSut(users, new FakeEmployeeRepository(), new FakeRoleRepository(role), refreshTokens: refreshTokens); + var oldStamp = user.SecurityStamp; + + var result = await sut.UpdateAsync(user.Id, role.Id, isActive: false); + + Assert.True(result.Success); + Assert.False(user.IsActive); + Assert.NotEqual(oldStamp, user.SecurityStamp); + var session = await refreshTokens.GetByTokenHashAsync("session-hash"); + Assert.NotNull(session!.RevokedAt); + } + + [Fact] + public async Task UpdateAsync_UnknownRole_Fails() + { + var role = new Role { Name = "Disposition" }; + var user = new User { Username = "u", PasswordHash = "hashed:x", RoleId = role.Id, Role = role }; + var users = new FakeUserRepository(user); + var sut = CreateSut(users, new FakeEmployeeRepository(), new FakeRoleRepository(role)); + + var result = await sut.UpdateAsync(user.Id, Guid.NewGuid(), isActive: true); + + Assert.False(result.Success); + Assert.Equal(UpdateUserFailureReason.RoleNotFound, result.FailureReason); + } + + [Fact] + public async Task ChangeOwnPasswordAsync_WithWrongCurrentPassword_Fails() + { + var role = new Role { Name = "Disposition" }; + var user = new User + { + Username = "muss.wechseln", + PasswordHash = "hashed:altesPasswort", + RoleId = role.Id, + Role = role, + MustChangePassword = true + }; + var users = new FakeUserRepository(user); + + var sut = CreateSut(users, new FakeEmployeeRepository(), new FakeRoleRepository()); + + var success = await sut.ChangeOwnPasswordAsync(user.Id, "falschesPasswort", "neuesPasswort"); + + Assert.False(success); + Assert.True(user.MustChangePassword); + } +} diff --git a/omsorgCore/tests/OmsorgCore.Tests/TestDoubles/FakeRepositories.cs b/omsorgCore/tests/OmsorgCore.Tests/TestDoubles/FakeRepositories.cs new file mode 100644 index 0000000..3e009d4 --- /dev/null +++ b/omsorgCore/tests/OmsorgCore.Tests/TestDoubles/FakeRepositories.cs @@ -0,0 +1,287 @@ +using OmsorgCore.Application.Abstractions; +using OmsorgCore.Domain.Entities; + +namespace OmsorgCore.Tests.TestDoubles; + +public class FakeUserRepository : IUserRepository +{ + private readonly List _users; + + public FakeUserRepository(params User[] users) + { + _users = users.ToList(); + } + + public Task GetByUsernameAsync(string username, CancellationToken cancellationToken = default) + => Task.FromResult(_users.FirstOrDefault(u => u.Username == username)); + + public Task GetByIdWithPermissionsAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(_users.FirstOrDefault(u => u.Id == id)); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(_users.FirstOrDefault(u => u.Id == id)); + + public Task GetByIdWithEmployeeAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(_users.FirstOrDefault(u => u.Id == id)); + + public Task> GetAllAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(_users.ToList()); + + public Task GetByEmployeeIdAsync(Guid employeeId, CancellationToken cancellationToken = default) + => Task.FromResult(_users.FirstOrDefault(u => u.EmployeeId == employeeId)); + + public Task ExistsByUsernameAsync(string username, CancellationToken cancellationToken = default) + => Task.FromResult(_users.Any(u => u.Username == username)); + + public Task AddAsync(User user, CancellationToken cancellationToken = default) + { + _users.Add(user); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +public class FakeRefreshTokenRepository : IRefreshTokenRepository +{ + private readonly Dictionary _tokensByHash = new(); + + public Task AddAsync(RefreshToken token, CancellationToken cancellationToken = default) + { + _tokensByHash[token.TokenHash] = token; + return Task.CompletedTask; + } + + public Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken = default) + => Task.FromResult(_tokensByHash.GetValueOrDefault(tokenHash)); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(_tokensByHash.Values.FirstOrDefault(t => t.Id == id)); + + public Task> GetAllActiveAsync(CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + IReadOnlyList active = _tokensByHash.Values + .Where(t => t.RevokedAt == null && t.ExpiresAt > now) + .ToList(); + return Task.FromResult(active); + } + + public Task> GetActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + IReadOnlyList active = _tokensByHash.Values + .Where(t => t.UserId == userId && t.RevokedAt == null && t.ExpiresAt > now) + .ToList(); + return Task.FromResult(active); + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +public class FakeEmployeeRepository : IEmployeeRepository +{ + private readonly List _employees = new(); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(_employees.FirstOrDefault(e => e.Id == id)); + + public Task> GetAllAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(_employees.ToList()); + + public Task<(IReadOnlyList Items, int TotalCount)> GetPagedAsync( + string? search, + string? status, + string? employmentType, + int page, + int pageSize, + CancellationToken cancellationToken = default) + { + IEnumerable query = _employees.Where(e => !e.IsDeleted); + + if (!string.IsNullOrWhiteSpace(search)) + { + var term = search.Trim(); + query = query.Where(e => + e.FirstName.Contains(term, StringComparison.OrdinalIgnoreCase) || + e.LastName.Contains(term, StringComparison.OrdinalIgnoreCase) || + (e.Email != null && e.Email.Contains(term, StringComparison.OrdinalIgnoreCase))); + } + + if (!string.IsNullOrWhiteSpace(status)) + { + query = query.Where(e => e.Status == status); + } + + if (!string.IsNullOrWhiteSpace(employmentType)) + { + query = query.Where(e => e.EmploymentType == employmentType); + } + + var ordered = query.OrderBy(e => e.LastName).ThenBy(e => e.FirstName).ToList(); + var totalCount = ordered.Count; + var items = ordered.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + + return Task.FromResult<(IReadOnlyList Items, int TotalCount)>((items, totalCount)); + } + + public Task AddAsync(Employee employee, CancellationToken cancellationToken = default) + { + _employees.Add(employee); + return Task.CompletedTask; + } + + public Task UpdateAsync(Employee employee, CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +public class FakeRoleRepository : IRoleRepository +{ + private readonly List _roles; + + public FakeRoleRepository(params Role[] roles) + { + _roles = roles.ToList(); + } + + public Task> GetAllAsync(CancellationToken cancellationToken = default) + => Task.FromResult>(_roles.ToList()); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) + => Task.FromResult(_roles.FirstOrDefault(r => r.Id == id)); + + public Task ExistsByNameAsync(string name, CancellationToken cancellationToken = default) + => Task.FromResult(_roles.Any(r => r.Name == name)); + + public Task AddAsync(Role role, CancellationToken cancellationToken = default) + { + _roles.Add(role); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +public class FakePasswordHasher : IPasswordHasher +{ + public string Hash(string plainPassword) => $"hashed:{plainPassword}"; + public bool Verify(string plainPassword, string hash) => hash == $"hashed:{plainPassword}"; +} + +public class FakeJwtTokenGenerator : IJwtTokenGenerator +{ + public (string Token, DateTime ExpiresAt) GenerateToken(User user) + => ($"access-token-for-{user.Id}-{Guid.NewGuid()}", DateTime.UtcNow.AddMinutes(60)); +} + +public class FakeRefreshTokenGenerator : IRefreshTokenGenerator +{ + public (string RawToken, string TokenHash, DateTime ExpiresAt) GenerateToken() + { + var rawToken = Guid.NewGuid().ToString(); + return (rawToken, HashToken(rawToken), DateTime.UtcNow.AddDays(60)); + } + + public string HashToken(string rawToken) => $"hash:{rawToken}"; +} + +public class FakePasswordResetCodeRepository : IPasswordResetCodeRepository +{ + private readonly List _codes = new(); + + public Task AddAsync(PasswordResetCode code, CancellationToken cancellationToken = default) + { + _codes.Add(code); + return Task.CompletedTask; + } + + public Task GetActiveByUserIdAsync(Guid userId, int maxAttempts, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + var active = _codes + .Where(c => c.UserId == userId && c.InvalidatedAt == null && c.ConsumedAt == null + && c.ExpiresAt > now && c.AttemptCount < maxAttempts) + .OrderByDescending(c => c.CreatedAt) + .FirstOrDefault(); + return Task.FromResult(active); + } + + public Task GetLatestByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) + => Task.FromResult(_codes.Where(c => c.UserId == userId).OrderByDescending(c => c.CreatedAt).FirstOrDefault()); + + public Task GetByResetTokenHashAsync(string resetTokenHash, CancellationToken cancellationToken = default) + => Task.FromResult(_codes.FirstOrDefault(c => c.ResetTokenHash == resetTokenHash)); + + public Task InvalidateActiveForUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + var now = DateTime.UtcNow; + foreach (var code in _codes.Where(c => c.UserId == userId && c.InvalidatedAt == null && c.ConsumedAt == null && c.ExpiresAt > now)) + { + code.InvalidatedAt = now; + } + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +public class FakePasswordResetCodeGenerator : IPasswordResetCodeGenerator +{ + public TimeSpan RequestCooldown { get; set; } = TimeSpan.Zero; + public int MaxAttempts { get; set; } = 3; + + public (string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin() + => GeneratePin(TimeSpan.FromMinutes(5)); + + public (string RawPin, string PinHash, DateTime ExpiresAt) GeneratePin(TimeSpan validity) + { + var rawPin = "123456"; + return (rawPin, HashPin(rawPin), DateTime.UtcNow.Add(validity)); + } + + public (string RawResetToken, string ResetTokenHash, DateTime ExpiresAt) GenerateResetToken() + { + var rawResetToken = Guid.NewGuid().ToString(); + return (rawResetToken, HashResetToken(rawResetToken), DateTime.UtcNow.AddMinutes(10)); + } + + public string HashPin(string rawPin) => $"pinhash:{rawPin}"; + + public string HashResetToken(string rawResetToken) => $"resethash:{rawResetToken}"; +} + +public class FakeLoginAttemptRepository : ILoginAttemptRepository +{ + private readonly List _attempts = new(); + + public Task CountRecentFailuresAsync(string ipAddress, DateTime since, CancellationToken cancellationToken = default) + => Task.FromResult(_attempts.Count(a => a.IpAddress == ipAddress && !a.Succeeded && a.AttemptedAt > since)); + + public Task AddAsync(LoginAttempt attempt, CancellationToken cancellationToken = default) + { + _attempts.Add(attempt); + return Task.CompletedTask; + } + + public Task ClearFailuresAsync(string ipAddress, CancellationToken cancellationToken = default) + { + _attempts.RemoveAll(a => a.IpAddress == ipAddress && !a.Succeeded); + return Task.CompletedTask; + } + + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +public class FakeLoginLockoutPolicy : ILoginLockoutPolicy +{ + public int MaxFailures { get; set; } = 5; + public TimeSpan LockoutWindow { get; set; } = TimeSpan.FromMinutes(10); +} diff --git a/omsorgWeb/CLAUDE.md b/omsorgWeb/CLAUDE.md new file mode 100644 index 0000000..d93ff3d --- /dev/null +++ b/omsorgWeb/CLAUDE.md @@ -0,0 +1,118 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the OMSORG website plus **OMSORG Connect**, an internal employee web app ("Mitarbeiter-App") for a German nursing care company. There is no build system — everything is plain PHP, HTML, CSS, and vanilla JS deployed directly to a web server (`htdocs`). + +This project is one part of the OMSORG monorepo — see the root `CLAUDE.md` (`../CLAUDE.md`) for the overall platform picture and `../REQUIREMENTS.md` for functional/non-functional requirements with FR-IDs. This app is the reference implementation for **OMSORG Connect**; its MySQL database is expected to eventually move behind the shared `omsorgCore` backend (currently empty, planned as C#/.NET + PostgreSQL) rather than staying a standalone data store. + +## Deployment + +Upload the full directory contents to the server's `htdocs` folder and overwrite existing files. After deploying, clear the browser cache or test in an incognito window. + +There are no build steps, package managers, or test runners. + +## Architecture + +### Public website (`/`) +Static HTML with a contact form: +- `index.html` — main landing page +- `send-form.php` — handles the public contact form, sends email via PHP `mail()` +- `send-status-template.php` — reusable status page template for form results +- `datenschutz.html`, `impressum.html` — legal pages + +### Employee app (`/mitarbeiter-app/`) +A session-based PHP app with no framework, backed by a **MySQL database** (PDO). + +#### Entry point +- `index.php` — login page. Redirects to `pages/dashboard.php` if already logged in. Includes rate limiting: 5 failed attempts trigger a 10-minute lockout. +- `setup.php` — one-time setup script (run once after first deploy to bootstrap the DB) + +#### Library (`lib/`) +- `auth.php` — included at the top of every protected page. Provides `require_login()`, `require_admin()`, `is_logged_in()`, `is_admin()`, `current_user()`, `current_name()`, `current_username()`, `csrf_token()`, `csrf_field()`, `verify_csrf()`, `e()`. Users are stored in the `users` DB table. Login is case-insensitive on username. +- `db.php` — provides `db(): PDO` (singleton). Reads credentials from `config.php`, runs pending migrations on first connection, and sets `PDO::ERRMODE_EXCEPTION`. +- `config.php` — returns array with MySQL DSN/credentials, SMTP settings, and mail recipient addresses. Import with `$config = require __DIR__ . '/config.php';`. +- `layout.php` — shared sidebar layout. Call `layout_start($title, $current_page)` and `layout_end()` to wrap page content. Renders the sidebar nav, user avatar, and injects the service worker. +- `mail.php` — provides `smtp_send($cfg, $to, $subject, $body, $attach = [])`. Raw SMTP implementation (no PHPMailer), supports SSL (port 465) and STARTTLS (port 587), and optional file attachments. + +#### Pages (`pages/`) +Each page is a standalone PHP file using `layout_start`/`layout_end`: +- `dashboard.php` — main landing page after login; shows news and quick links +- `stundennachweis.php` — upload monthly timesheet (PDF/image) +- `urlaubsantrag.php` — vacation request form +- `abwesenheitsantrag.php` — absence request form (sick leave, etc.) +- `benefitsantrag.php` — employee benefits request +- `fortbildungsantrag.php` — training request with optional file upload +- `einsatzbewertung.php` — rate a deployment/assignment +- `einsatzanweisung.php` — view/download personal assignment instructions (PDF) +- `dokumentenarchiv.php` — personal document archive (upload/view own documents) +- `downloads.php` — company-wide file downloads (admin-managed) +- `dienstplan.php` — view personal shift schedule +- `werben.php` — refer a new employee +- `settings.php` — profile settings: change password, upload avatar +- `admin.php` — admin-only: tabs for Anträge, Nutzer, Dienstplan, Downloads, Fortbildungsmaterial, Stundennachweis, Bewertungen, News, Einsatzanweisung, Login-Versuche (failed login attempts: lists recent attempts, shows currently locked IPs, and clears the `login_attempts` table) +- `admin-dienstplan.php` — admin shift planner view +- Serve pages (`*-serve.php`, `dokument-serve.php`, `download-serve.php`, etc.) — stream protected files from `uploads/`, `downloads/`, `fortbildung-materials/` with auth check + +#### Actions (`actions/`) +POST-only endpoints, each does one thing and redirects back: +- `submit-urlaubsantrag.php`, `submit-abwesenheitsantrag.php`, `submit-benefitsantrag.php`, `submit-fortbildungsantrag.php`, `submit-stundennachweis.php`, `submit-einsatzbewertung.php`, `submit-werben.php` — insert into the matching `requests_*` DB table, send notification email via `smtp_send()` +- `admin-action.php` — admin status updates (accept/reject requests), user management (add/delete/reset password), news CRUD +- `save-dienstplan.php` — admin saves shift entries +- `downloads-action.php`, `downloads-reorder.php` — admin manages downloadable files +- `fortbildung-material-action.php`, `fortbildung-material-reorder.php` — admin manages training materials +- `einsatzanweisung-action.php` — admin uploads/assigns personal instruction PDFs +- `upload-dokument.php`, `delete-dokument.php` — user document archive management +- `upload-avatar.php` — user uploads profile picture (stored in `assets/avatars/`) +- `save-profile.php` — user changes own password +- `logout.php` — destroys session, redirects to `../index.php` + +#### Frontend +- `app.css` — all styles (no framework) +- `manifest.webmanifest` + `service-worker.js` — PWA support ("Add to Home Screen") + +### Database schema (MySQL) +Managed via migrations in `migrations/`. Key tables: +- `users` — id, username, name, role, password_hash, active, created_at, email, telefon, avatar +- `requests_urlaubsantrag` — vacation requests (von, bis, vertretung, nachricht, status, admin_note) +- `requests_abwesenheitsantrag` — absence requests (von, bis, grund, vertretung, nachricht, status, admin_note) +- `requests_benefitsantrag` — benefits requests +- `requests_fortbildungsantrag` — training requests (with optional file upload) +- `requests_stundennachweis` — timesheet uploads (monat, filename) +- `requests_werben` — employee referrals +- `dienstplan` — shift schedule (user_id, date, schicht; UNIQUE on user_id+date) +- `downloads` — company download files (title, filename, sort_order) +- `fortbildung_materials` — training material files (title, filename, sort_order) +- `dokumente` — user personal document archive (user_id, kategorie, filename) +- `einsatzbewertungen` — deployment ratings +- `einsatzanweisung` — one PDF per user (UNIQUE on user_id) +- `news` — company news posts (title, text, date) +- `schema_migrations` — tracks applied migrations + +### Migration system +`db.php` runs `_run_migrations()` on every connection. Migrations are PHP files in `migrations/` returning `['description' => ..., 'up' => [...SQL...]]`. They are applied in filename order and tracked in `schema_migrations`. Already-existing DBs without the migrations table are stamped as fully applied on first run. + +### File storage +Uploaded files are stored outside webroot or protected by `.htaccess`: +- `uploads/` — user form attachments (stundennachweis, fortbildung, einsatzanweisung). Filename pattern: `{date}_{username}_{type}_{randomhex}.{ext}` +- `downloads/` — admin-managed company downloads (hashed filenames) +- `fortbildung-materials/` — admin-managed training materials (hashed filenames) +- `assets/avatars/` — user profile pictures (`{username}_{randomhex}.{ext}`) + +Allowed upload types: pdf, doc, docx, jpg, jpeg, png. Max size: 12 MB. + +### Security conventions +- `lib/` is blocked from direct HTTP access via `lib/.htaccess` +- `uploads/`, `downloads/`, `fortbildung-materials/`, `data/`, `migrations/` all have `.htaccess` denying direct access; files are served only through `*-serve.php` pages with auth checks +- All output uses `e()` (alias for `htmlspecialchars`) to prevent XSS +- Every POST form includes a CSRF token (`csrf_field()` in form, `verify_csrf()` in action) +- Login rate-limiting: 5 failures → 10-minute session lockout +- Uploads are renamed to random hex filenames before storage + +### Email (configured in `config.php`) +- SMTP via `smtp_send()` in `lib/mail.php`; credentials in `config.php` +- `mail_info` → `info@omsorg-pflegedienste.de` (Geschäftsführung) +- `mail_sabrina` → `s.berggoetz@omsorg-pflegedienste.de` (Disposition) +- `mail_from` → `no-reply@omsorg-pflegedienste.de` diff --git a/README.txt b/omsorgWeb/README.txt similarity index 100% rename from README.txt rename to omsorgWeb/README.txt diff --git a/assets/einrichtungen.svg b/omsorgWeb/assets/einrichtungen.svg similarity index 100% rename from assets/einrichtungen.svg rename to omsorgWeb/assets/einrichtungen.svg diff --git a/assets/omsorg-bg-full.png b/omsorgWeb/assets/omsorg-bg-full.png similarity index 100% rename from assets/omsorg-bg-full.png rename to omsorgWeb/assets/omsorg-bg-full.png diff --git a/assets/omsorg-logo-new.png b/omsorgWeb/assets/omsorg-logo-new.png similarity index 100% rename from assets/omsorg-logo-new.png rename to omsorgWeb/assets/omsorg-logo-new.png diff --git a/assets/omsorg-logo.png b/omsorgWeb/assets/omsorg-logo.png similarity index 100% rename from assets/omsorg-logo.png rename to omsorgWeb/assets/omsorg-logo.png diff --git a/assets/omsorg-team-transparent.png b/omsorgWeb/assets/omsorg-team-transparent.png similarity index 100% rename from assets/omsorg-team-transparent.png rename to omsorgWeb/assets/omsorg-team-transparent.png diff --git a/assets/omsorg-wordmark-new.png b/omsorgWeb/assets/omsorg-wordmark-new.png similarity index 100% rename from assets/omsorg-wordmark-new.png rename to omsorgWeb/assets/omsorg-wordmark-new.png diff --git a/assets/pflege-bewerbung.jpg b/omsorgWeb/assets/pflege-bewerbung.jpg similarity index 100% rename from assets/pflege-bewerbung.jpg rename to omsorgWeb/assets/pflege-bewerbung.jpg diff --git a/assets/pflege-einrichtungen.jpg b/omsorgWeb/assets/pflege-einrichtungen.jpg similarity index 100% rename from assets/pflege-einrichtungen.jpg rename to omsorgWeb/assets/pflege-einrichtungen.jpg diff --git a/assets/pflege-hero.jpg b/omsorgWeb/assets/pflege-hero.jpg similarity index 100% rename from assets/pflege-hero.jpg rename to omsorgWeb/assets/pflege-hero.jpg diff --git a/assets/pflege-training.jpg b/omsorgWeb/assets/pflege-training.jpg similarity index 100% rename from assets/pflege-training.jpg rename to omsorgWeb/assets/pflege-training.jpg diff --git a/assets/pflegekraefte.svg b/omsorgWeb/assets/pflegekraefte.svg similarity index 100% rename from assets/pflegekraefte.svg rename to omsorgWeb/assets/pflegekraefte.svg diff --git a/assets/team.svg b/omsorgWeb/assets/team.svg similarity index 100% rename from assets/team.svg rename to omsorgWeb/assets/team.svg diff --git a/datenschutz.html b/omsorgWeb/datenschutz.html similarity index 100% rename from datenschutz.html rename to omsorgWeb/datenschutz.html diff --git a/impressum.html b/omsorgWeb/impressum.html similarity index 100% rename from impressum.html rename to omsorgWeb/impressum.html diff --git a/index.html b/omsorgWeb/index.html similarity index 100% rename from index.html rename to omsorgWeb/index.html diff --git a/mitarbeiter-app/.htaccess b/omsorgWeb/mitarbeiter-app-legacy/.htaccess similarity index 100% rename from mitarbeiter-app/.htaccess rename to omsorgWeb/mitarbeiter-app-legacy/.htaccess diff --git a/mitarbeiter-app/actions/admin-action.php b/omsorgWeb/mitarbeiter-app-legacy/actions/admin-action.php similarity index 100% rename from mitarbeiter-app/actions/admin-action.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/admin-action.php diff --git a/mitarbeiter-app/actions/delete-dokument.php b/omsorgWeb/mitarbeiter-app-legacy/actions/delete-dokument.php similarity index 100% rename from mitarbeiter-app/actions/delete-dokument.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/delete-dokument.php diff --git a/mitarbeiter-app/actions/downloads-action.php b/omsorgWeb/mitarbeiter-app-legacy/actions/downloads-action.php similarity index 100% rename from mitarbeiter-app/actions/downloads-action.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/downloads-action.php diff --git a/mitarbeiter-app/actions/downloads-reorder.php b/omsorgWeb/mitarbeiter-app-legacy/actions/downloads-reorder.php similarity index 100% rename from mitarbeiter-app/actions/downloads-reorder.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/downloads-reorder.php diff --git a/mitarbeiter-app/actions/einsatzanweisung-action.php b/omsorgWeb/mitarbeiter-app-legacy/actions/einsatzanweisung-action.php similarity index 100% rename from mitarbeiter-app/actions/einsatzanweisung-action.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/einsatzanweisung-action.php diff --git a/mitarbeiter-app/actions/fortbildung-material-action.php b/omsorgWeb/mitarbeiter-app-legacy/actions/fortbildung-material-action.php similarity index 100% rename from mitarbeiter-app/actions/fortbildung-material-action.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/fortbildung-material-action.php diff --git a/mitarbeiter-app/actions/fortbildung-material-reorder.php b/omsorgWeb/mitarbeiter-app-legacy/actions/fortbildung-material-reorder.php similarity index 100% rename from mitarbeiter-app/actions/fortbildung-material-reorder.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/fortbildung-material-reorder.php diff --git a/mitarbeiter-app/actions/logout.php b/omsorgWeb/mitarbeiter-app-legacy/actions/logout.php similarity index 100% rename from mitarbeiter-app/actions/logout.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/logout.php diff --git a/mitarbeiter-app/actions/news-action.php b/omsorgWeb/mitarbeiter-app-legacy/actions/news-action.php similarity index 100% rename from mitarbeiter-app/actions/news-action.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/news-action.php diff --git a/mitarbeiter-app/actions/save-dienstplan.php b/omsorgWeb/mitarbeiter-app-legacy/actions/save-dienstplan.php similarity index 100% rename from mitarbeiter-app/actions/save-dienstplan.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/save-dienstplan.php diff --git a/omsorgWeb/mitarbeiter-app-legacy/actions/save-profile.php b/omsorgWeb/mitarbeiter-app-legacy/actions/save-profile.php new file mode 100644 index 0000000..79b820e --- /dev/null +++ b/omsorgWeb/mitarbeiter-app-legacy/actions/save-profile.php @@ -0,0 +1,74 @@ + strtotime($bis)) { + redirect_error($back, 'Das Startdatum muss vor dem Enddatum liegen.'); +} + +$up = handle_upload($_FILES['datei'] ?? [], [ + 'allowed' => UPLOAD_TYPES_DOCS, + 'dir' => dirname(__DIR__) . '/uploads/', + 'prefix' => 'urlaubsantrag', + 'required' => false, +]); +if (!$up['ok']) { + redirect_error($back, $up['error']); +} + +$filename = $up['filename'] ?? ''; +$original_name = $up['original_name'] ?? ''; +$mime = $up['mime'] ?? null; + +$user_id = current_user()['id']; +$now = date('c'); + +db()->prepare( + 'INSERT INTO requests_urlaubsantrag (user_id, status, admin_note, created_at, updated_at, von, bis, vertretung, nachricht, filename, original_name) + VALUES (?, \'pending\', \'\', ?, ?, ?, ?, ?, ?, ?, ?)' +)->execute([$user_id, $now, $now, $von, $bis, $vertretung, $nachricht, $filename, $original_name]); + +$config = require __DIR__ . '/../lib/config.php'; +$attach = ($filename !== '') + ? ['path' => dirname(__DIR__) . '/uploads/' . $filename, 'name' => $original_name, 'mime' => $mime] + : []; +smtp_send( + $config, + $config['mail_sabrina'], + 'Neuer Urlaubsantrag: ' . current_name(), + "Ein Urlaubsantrag wurde eingereicht.\n\n" + . 'Mitarbeiter: ' . current_name() . "\n" + . 'Von: ' . $von . "\n" + . 'Bis: ' . $bis . "\n" + . 'Vertretung: ' . ($vertretung ?: '—') . "\n" + . 'Nachricht: ' . ($nachricht ?: '—') . "\n\n" + . "---\nGesendet über OMSORG Connect", + $attach +); + +redirect_ok($back); diff --git a/mitarbeiter-app/actions/submit-werben.php b/omsorgWeb/mitarbeiter-app-legacy/actions/submit-werben.php similarity index 100% rename from mitarbeiter-app/actions/submit-werben.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/submit-werben.php diff --git a/mitarbeiter-app/actions/upload-avatar.php b/omsorgWeb/mitarbeiter-app-legacy/actions/upload-avatar.php similarity index 56% rename from mitarbeiter-app/actions/upload-avatar.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/upload-avatar.php index 5220845..7f7b551 100644 --- a/mitarbeiter-app/actions/upload-avatar.php +++ b/omsorgWeb/mitarbeiter-app-legacy/actions/upload-avatar.php @@ -8,10 +8,32 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } -$back = '../pages/settings.php'; -$username = current_username(); -$user = current_user(); -$dir = dirname(__DIR__) . '/assets/avatars/'; +$back = '../pages/settings.php'; +$user = current_user(); +$dir = dirname(__DIR__) . '/assets/avatars/'; +$config = _omsorgcore_config(); +$token = $_SESSION['omsorgcore_access_token']; +$employeeId = $_SESSION['omsorgcore_profile']['employeeId'] ?? null; + +function _set_avatar_file_name(array $config, string $token, string $employeeId, ?string $filename): bool { + $current = omsorgcore_employees_get($config, $token, $employeeId); + if (!$current['ok']) { + return false; + } + $payload = $current['data']; + $payload['avatarFileName'] = $filename; + unset($payload['id']); + + $result = omsorgcore_employees_update($config, $token, $employeeId, $payload); + if ($result['ok']) { + _refresh_profile(); + } + return $result['ok']; +} + +if (!$employeeId) { + redirect_error($back, 'Kein verknüpfter Mitarbeiter-Datensatz gefunden.'); +} // Remove avatar if (!empty($_POST['remove_avatar'])) { @@ -21,7 +43,7 @@ if (!empty($_POST['remove_avatar'])) { unlink($old); } } - db()->prepare('UPDATE users SET avatar = NULL WHERE username = ?')->execute([$username]); + _set_avatar_file_name($config, $token, $employeeId, null); redirect_ok($back, 'avatar_saved'); } @@ -53,6 +75,6 @@ if (!empty($user['avatar'])) { } } -db()->prepare('UPDATE users SET avatar = ? WHERE username = ?')->execute([$safe_name, $username]); +_set_avatar_file_name($config, $token, $employeeId, $safe_name); redirect_ok($back, 'avatar_saved'); diff --git a/mitarbeiter-app/actions/upload-dokument.php b/omsorgWeb/mitarbeiter-app-legacy/actions/upload-dokument.php similarity index 100% rename from mitarbeiter-app/actions/upload-dokument.php rename to omsorgWeb/mitarbeiter-app-legacy/actions/upload-dokument.php diff --git a/mitarbeiter-app/app.css b/omsorgWeb/mitarbeiter-app-legacy/app.css similarity index 100% rename from mitarbeiter-app/app.css rename to omsorgWeb/mitarbeiter-app-legacy/app.css diff --git a/mitarbeiter-app/assets/Liberty.jpeg b/omsorgWeb/mitarbeiter-app-legacy/assets/Liberty.jpeg similarity index 100% rename from mitarbeiter-app/assets/Liberty.jpeg rename to omsorgWeb/mitarbeiter-app-legacy/assets/Liberty.jpeg diff --git a/mitarbeiter-app/assets/omsorg-bg-full.png b/omsorgWeb/mitarbeiter-app-legacy/assets/omsorg-bg-full.png similarity index 100% rename from mitarbeiter-app/assets/omsorg-bg-full.png rename to omsorgWeb/mitarbeiter-app-legacy/assets/omsorg-bg-full.png diff --git a/mitarbeiter-app/assets/omsorg-logo-new.png b/omsorgWeb/mitarbeiter-app-legacy/assets/omsorg-logo-new.png similarity index 100% rename from mitarbeiter-app/assets/omsorg-logo-new.png rename to omsorgWeb/mitarbeiter-app-legacy/assets/omsorg-logo-new.png diff --git a/mitarbeiter-app/assets/omsorg-team-transparent.png b/omsorgWeb/mitarbeiter-app-legacy/assets/omsorg-team-transparent.png similarity index 100% rename from mitarbeiter-app/assets/omsorg-team-transparent.png rename to omsorgWeb/mitarbeiter-app-legacy/assets/omsorg-team-transparent.png diff --git a/mitarbeiter-app/assets/omsorg-wordmark-new.png b/omsorgWeb/mitarbeiter-app-legacy/assets/omsorg-wordmark-new.png similarity index 100% rename from mitarbeiter-app/assets/omsorg-wordmark-new.png rename to omsorgWeb/mitarbeiter-app-legacy/assets/omsorg-wordmark-new.png diff --git a/mitarbeiter-app/font/kindelserif/1001fonts-kindelserif-eula.txt b/omsorgWeb/mitarbeiter-app-legacy/font/kindelserif/1001fonts-kindelserif-eula.txt similarity index 100% rename from mitarbeiter-app/font/kindelserif/1001fonts-kindelserif-eula.txt rename to omsorgWeb/mitarbeiter-app-legacy/font/kindelserif/1001fonts-kindelserif-eula.txt diff --git a/mitarbeiter-app/font/kindelserif/KindelSerif_PersonalUse.otf b/omsorgWeb/mitarbeiter-app-legacy/font/kindelserif/KindelSerif_PersonalUse.otf similarity index 100% rename from mitarbeiter-app/font/kindelserif/KindelSerif_PersonalUse.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/kindelserif/KindelSerif_PersonalUse.otf diff --git a/mitarbeiter-app/font/visby/VisbyBold Italic.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyBold Italic.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyBold Italic.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyBold Italic.otf diff --git a/mitarbeiter-app/font/visby/VisbyBold.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyBold.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyBold.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyBold.otf diff --git a/mitarbeiter-app/font/visby/VisbyExtrabold Italic.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyExtrabold Italic.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyExtrabold Italic.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyExtrabold Italic.otf diff --git a/mitarbeiter-app/font/visby/VisbyExtrabold.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyExtrabold.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyExtrabold.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyExtrabold.otf diff --git a/mitarbeiter-app/font/visby/VisbyHeavy Italic.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyHeavy Italic.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyHeavy Italic.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyHeavy Italic.otf diff --git a/mitarbeiter-app/font/visby/VisbyHeavy.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyHeavy.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyHeavy.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyHeavy.otf diff --git a/mitarbeiter-app/font/visby/VisbyItalic.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyItalic.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyItalic.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyItalic.otf diff --git a/mitarbeiter-app/font/visby/VisbyLight Italic.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyLight Italic.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyLight Italic.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyLight Italic.otf diff --git a/mitarbeiter-app/font/visby/VisbyLight.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyLight.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyLight.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyLight.otf diff --git a/mitarbeiter-app/font/visby/VisbyMedium Italic.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyMedium Italic.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyMedium Italic.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyMedium Italic.otf diff --git a/mitarbeiter-app/font/visby/VisbyMedium.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyMedium.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyMedium.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyMedium.otf diff --git a/mitarbeiter-app/font/visby/VisbyRegular.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyRegular.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyRegular.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyRegular.otf diff --git a/mitarbeiter-app/font/visby/VisbySemibold Italic.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbySemibold Italic.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbySemibold Italic.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbySemibold Italic.otf diff --git a/mitarbeiter-app/font/visby/VisbySemibold.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbySemibold.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbySemibold.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbySemibold.otf diff --git a/mitarbeiter-app/font/visby/VisbyThin Italic.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyThin Italic.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyThin Italic.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyThin Italic.otf diff --git a/mitarbeiter-app/font/visby/VisbyThin.otf b/omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyThin.otf similarity index 100% rename from mitarbeiter-app/font/visby/VisbyThin.otf rename to omsorgWeb/mitarbeiter-app-legacy/font/visby/VisbyThin.otf diff --git a/mitarbeiter-app/index.php b/omsorgWeb/mitarbeiter-app-legacy/index.php similarity index 63% rename from mitarbeiter-app/index.php rename to omsorgWeb/mitarbeiter-app-legacy/index.php index 1e05981..b5834e4 100644 --- a/mitarbeiter-app/index.php +++ b/omsorgWeb/mitarbeiter-app-legacy/index.php @@ -6,44 +6,35 @@ if (is_logged_in()) { exit; } -$error = ''; -$locked = false; +$error = ''; -$MAX_FAILS = 5; -$LOCKOUT_SEC = 600; -$ip = $_SERVER['REMOTE_ADDR'] ?? ''; - -$fails = login_recent_fails($ip, $LOCKOUT_SEC); -if ($fails >= $MAX_FAILS) { - $locked = true; - $error = 'Zu viele Fehlversuche. Bitte warte ca. 10 Minuten.'; -} - -if (!$locked && $_SERVER['REQUEST_METHOD'] === 'POST') { +if ($_SERVER['REQUEST_METHOD'] === 'POST') { verify_csrf(); $username = trim($_POST['username'] ?? ''); $password = $_POST['password'] ?? ''; if ($username !== '' && $password !== '') { - $stmt = db()->prepare( - 'SELECT * FROM users WHERE LOWER(username) = LOWER(?) AND active = 1' - ); - $stmt->execute([$username]); - $user = $stmt->fetch(); + $result = omsorgcore_login(_omsorgcore_config(), $username, $password); - if ($user && password_verify($password, $user['password_hash'])) { - login_clear_fails($ip); + if ($result['ok']) { session_regenerate_id(true); - $_SESSION['felix_logged_in'] = true; - $_SESSION['felix_username'] = $user['username']; - $_SESSION['felix_name'] = $user['name']; - $_SESSION['felix_role'] = $user['role']; - header('Location: pages/dashboard.php'); + _apply_token_pair($result['data']); + _refresh_profile(); + + header('Location: ' . (must_change_password() ? 'pages/change-password.php' : 'pages/dashboard.php')); exit; } + + if ($result['status'] === 429) { + $error = 'Zu viele Fehlversuche. Bitte warte ca. 10 Minuten.'; + } elseif ($result['status'] === 0) { + $error = 'Server nicht erreichbar. Bitte später erneut versuchen.'; + } else { + $error = 'Benutzername oder Passwort falsch.'; + } + } else { + $error = 'Benutzername oder Passwort falsch.'; } - login_record_fail($ip, $username ?? ''); - $error = 'Benutzername oder Passwort falsch.'; } ?> @@ -69,6 +60,8 @@ if (!$locked && $_SERVER['REQUEST_METHOD'] === 'POST') {
+ +
Passwort geändert. Bitte melde dich mit deinem neuen Passwort an.
@@ -84,6 +77,10 @@ if (!$locked && $_SERVER['REQUEST_METHOD'] === 'POST') {
+ +

+ Passwort vergessen? +

diff --git a/mitarbeiter-app/lib/.htaccess b/omsorgWeb/mitarbeiter-app-legacy/lib/.htaccess similarity index 100% rename from mitarbeiter-app/lib/.htaccess rename to omsorgWeb/mitarbeiter-app-legacy/lib/.htaccess diff --git a/omsorgWeb/mitarbeiter-app-legacy/lib/auth.php b/omsorgWeb/mitarbeiter-app-legacy/lib/auth.php new file mode 100644 index 0000000..9847999 --- /dev/null +++ b/omsorgWeb/mitarbeiter-app-legacy/lib/auth.php @@ -0,0 +1,209 @@ + 0, + 'path' => '/', + 'httponly' => true, + 'secure' => $secure, + 'samesite' => 'Lax', +]); +session_name('OMSORG_FELIX_APP'); +session_start(); + +function _omsorgcore_config(): array { + static $config = null; + if ($config === null) { + $config = require __DIR__ . '/config.php'; + } + return $config; +} + +// Stellt sicher, dass $_SESSION['omsorgcore_access_token'] gültig ist - erneuert bei Bedarf still +// über den Refresh-Token (Server-seitig, der Browser bekommt nie einen Token zu sehen). +function _ensure_fresh_token(): bool { + if (empty($_SESSION['omsorgcore_access_token']) || empty($_SESSION['omsorgcore_refresh_token'])) { + return false; + } + + $expiresAt = $_SESSION['omsorgcore_access_expires_at'] ?? null; + $stillValid = $expiresAt && strtotime($expiresAt) > time() + 30; // 30s Puffer + if ($stillValid) { + return true; + } + + $result = omsorgcore_refresh(_omsorgcore_config(), $_SESSION['omsorgcore_refresh_token']); + if (!$result['ok']) { + _clear_omsorgcore_session(); + return false; + } + + _apply_token_pair($result['data']); + return true; +} + +function _apply_token_pair(array $data): void { + $_SESSION['omsorgcore_access_token'] = $data['accessToken']; + $_SESSION['omsorgcore_refresh_token'] = $data['refreshToken']; + $_SESSION['omsorgcore_access_expires_at'] = $data['expiresAt']; + $_SESSION['omsorgcore_must_change_password'] = !empty($data['mustChangePassword']); +} + +function _clear_omsorgcore_session(): void { + unset( + $_SESSION['omsorgcore_access_token'], + $_SESSION['omsorgcore_refresh_token'], + $_SESSION['omsorgcore_access_expires_at'], + $_SESSION['omsorgcore_must_change_password'], + $_SESSION['omsorgcore_profile'], + $_SESSION['felix_local_user_id'] + ); +} + +// Schreibt Name/E-Mail/Telefon/Avatar/Aktiv-Status in die lokale users-Zeile (per omsorgcore_user_id +// gefunden, siehe migrate-users-to-core.php) - hält den Lesecache für die noch nicht migrierten +// Seiten aktuell. Legt die Zeile an, falls sie fehlt (z. B. gerade erst über admin.php angelegter User). +function _sync_local_user_cache(string $omsorgcoreUserId, string $username, array $profile): int { + $name = trim(($profile['firstName'] ?? '') . ' ' . ($profile['lastName'] ?? '')) ?: $username; + + $stmt = db()->prepare('SELECT id FROM users WHERE omsorgcore_user_id = ?'); + $stmt->execute([$omsorgcoreUserId]); + $row = $stmt->fetch(); + + $role = $profile['role'] ?? ''; + + if ($row) { + db()->prepare('UPDATE users SET username=?, name=?, role=?, email=?, telefon=?, avatar=?, active=1 WHERE id=?') + ->execute([$username, $name, $role, $profile['email'] ?? '', $profile['phoneNumber'] ?? '', $profile['avatarFileName'] ?? null, $row['id']]); + return (int)$row['id']; + } + + db()->prepare( + 'INSERT INTO users (username, name, role, password_hash, active, created_at, email, telefon, avatar, omsorgcore_user_id, omsorgcore_employee_id) + VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)' + )->execute([ + $username, $name, $role, '', date('c'), + $profile['email'] ?? '', $profile['phoneNumber'] ?? '', $profile['avatarFileName'] ?? null, + $omsorgcoreUserId, $profile['employeeId'] ?? null, + ]); + return (int)db()->lastInsertId(); +} + +// Lädt/cached /api/auth/me in der Session (Username/Rolle/Rechte/Employee-Basisdaten) und hält den +// lokalen Lesecache (siehe oben) aktuell. Wird nach Login und bei jedem is_logged_in()-Aufruf ohne +// gültigen Cache erneuert. +function _refresh_profile(): bool { + $result = omsorgcore_me(_omsorgcore_config(), $_SESSION['omsorgcore_access_token']); + if (!$result['ok']) { + return false; + } + + $_SESSION['omsorgcore_profile'] = $result['data']; + $_SESSION['felix_local_user_id'] = _sync_local_user_cache( + $result['data']['userId'], $result['data']['username'], $result['data'] + ); + + return true; +} + +function is_logged_in(): bool { + if (!_ensure_fresh_token()) { + return false; + } + if (empty($_SESSION['omsorgcore_profile'])) { + return _refresh_profile(); + } + return true; +} + +function must_change_password(): bool { + return !empty($_SESSION['omsorgcore_must_change_password']); +} + +function require_login(): void { + if (!is_logged_in()) { + header('Location: ../index.php'); + exit; + } + if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST)) { + verify_csrf(); + } + if (must_change_password() && basename($_SERVER['SCRIPT_NAME']) !== 'change-password.php') { + header('Location: change-password.php'); + exit; + } +} + +function csrf_token(): string { + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; +} + +function csrf_field(): string { + return ''; +} + +function verify_csrf(): void { + $token = $_POST['csrf_token'] ?? ''; + if (!hash_equals(csrf_token(), $token)) { + http_response_code(403); + exit('Ungültige Anfrage.'); + } +} + +function require_admin(): void { + require_login(); + if (!is_admin()) { + header('Location: ../pages/dashboard.php'); + exit; + } +} + +function is_admin(): bool { + return ($_SESSION['omsorgcore_profile']['role'] ?? null) === 'Administrator'; +} + +// Unverändert wie vorher: current_user()['id']/['name']/['email']/['telefon']/['avatar'] kommen aus +// der lokalen users-Zeile (jetzt ein Lesecache, siehe oben) - bestehende Seiten laufen unverändert. +function current_user(): array|false { + if (empty($_SESSION['felix_local_user_id'])) { + return false; + } + $stmt = db()->prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$_SESSION['felix_local_user_id']]); + $row = $stmt->fetch(); + if (!$row) { + return false; + } + $row['role'] = $_SESSION['omsorgcore_profile']['role'] ?? $row['role']; + return $row; +} + +function current_username(): string { + return $_SESSION['omsorgcore_profile']['username'] ?? ''; +} + +function current_name(): string { + $user = current_user(); + return $user ? $user['name'] : ''; +} + +function e(string $s): string { + return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} diff --git a/mitarbeiter-app/lib/config.secret.example.php b/omsorgWeb/mitarbeiter-app-legacy/lib/config.secret.example.php similarity index 58% rename from mitarbeiter-app/lib/config.secret.example.php rename to omsorgWeb/mitarbeiter-app-legacy/lib/config.secret.example.php index 9426e03..d37c809 100644 --- a/mitarbeiter-app/lib/config.secret.example.php +++ b/omsorgWeb/mitarbeiter-app-legacy/lib/config.secret.example.php @@ -5,4 +5,6 @@ return [ 'db_user' => 'DEIN_DB_BENUTZER', 'db_password' => 'DEIN_DB_PASSWORT', 'smtp_password' => 'DEIN_SMTP_PASSWORT', + // Produktiv auf die echte omsorgCore-URL überschreiben (Default in config.php ist localhost, nur für lokale Entwicklung): + // 'omsorg_core_url' => 'https://core.omsorg-pflegedienste.de', ]; diff --git a/mitarbeiter-app/lib/db.php b/omsorgWeb/mitarbeiter-app-legacy/lib/db.php similarity index 100% rename from mitarbeiter-app/lib/db.php rename to omsorgWeb/mitarbeiter-app-legacy/lib/db.php diff --git a/mitarbeiter-app/lib/layout.php b/omsorgWeb/mitarbeiter-app-legacy/lib/layout.php similarity index 100% rename from mitarbeiter-app/lib/layout.php rename to omsorgWeb/mitarbeiter-app-legacy/lib/layout.php diff --git a/mitarbeiter-app/lib/mail.php b/omsorgWeb/mitarbeiter-app-legacy/lib/mail.php similarity index 100% rename from mitarbeiter-app/lib/mail.php rename to omsorgWeb/mitarbeiter-app-legacy/lib/mail.php diff --git a/omsorgWeb/mitarbeiter-app-legacy/lib/omsorgCoreClient.php b/omsorgWeb/mitarbeiter-app-legacy/lib/omsorgCoreClient.php new file mode 100644 index 0000000..46c4714 --- /dev/null +++ b/omsorgWeb/mitarbeiter-app-legacy/lib/omsorgCoreClient.php @@ -0,0 +1,145 @@ + bool, 'status' => int, 'data' => array|null]. + +function omsorgcore_request(array $config, string $method, string $path, ?array $body = null, ?string $accessToken = null): array +{ + $url = rtrim($config['omsorg_core_url'], '/') . $path; + + $headers = ['Content-Type: application/json']; + if ($accessToken !== null) { + $headers[] = 'Authorization: Bearer ' . $accessToken; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 15, + CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null, + ]); + + $responseBody = curl_exec($ch); + if ($responseBody === false) { + $error = curl_error($ch); + curl_close($ch); + return ['ok' => false, 'status' => 0, 'data' => null, 'error' => $error]; + } + + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $data = null; + if ($responseBody !== '') { + $decoded = json_decode($responseBody, true); + $data = json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } + + return ['ok' => $status >= 200 && $status < 300, 'status' => $status, 'data' => $data]; +} + +// --- Auth --- + +function omsorgcore_login(array $config, string $username, string $password): array +{ + return omsorgcore_request($config, 'POST', '/api/auth/login', ['username' => $username, 'password' => $password]); +} + +function omsorgcore_refresh(array $config, string $refreshToken): array +{ + return omsorgcore_request($config, 'POST', '/api/auth/refresh', ['refreshToken' => $refreshToken]); +} + +function omsorgcore_logout(array $config, string $refreshToken): array +{ + return omsorgcore_request($config, 'POST', '/api/auth/logout', ['refreshToken' => $refreshToken]); +} + +function omsorgcore_me(array $config, string $accessToken): array +{ + return omsorgcore_request($config, 'GET', '/api/auth/me', null, $accessToken); +} + +function omsorgcore_change_password(array $config, string $accessToken, string $currentPassword, string $newPassword): array +{ + return omsorgcore_request($config, 'POST', '/api/auth/change-password', [ + 'currentPassword' => $currentPassword, + 'newPassword' => $newPassword, + ], $accessToken); +} + +function omsorgcore_forgot_password_request(array $config, string $username): array +{ + return omsorgcore_request($config, 'POST', '/api/auth/forgot-password/request', ['username' => $username]); +} + +function omsorgcore_forgot_password_verify(array $config, string $username, string $pin): array +{ + return omsorgcore_request($config, 'POST', '/api/auth/forgot-password/verify', ['username' => $username, 'pin' => $pin]); +} + +function omsorgcore_forgot_password_reset(array $config, string $resetToken, string $newPassword): array +{ + return omsorgcore_request($config, 'POST', '/api/auth/forgot-password/reset', [ + 'resetToken' => $resetToken, + 'newPassword' => $newPassword, + ]); +} + +// --- Users --- + +function omsorgcore_users_list(array $config, string $accessToken): array +{ + return omsorgcore_request($config, 'GET', '/api/users', null, $accessToken); +} + +function omsorgcore_users_create(array $config, string $accessToken, array $payload): array +{ + return omsorgcore_request($config, 'POST', '/api/users', $payload, $accessToken); +} + +function omsorgcore_users_update(array $config, string $accessToken, string $userId, array $payload): array +{ + return omsorgcore_request($config, 'PUT', '/api/users/' . urlencode($userId), $payload, $accessToken); +} + +function omsorgcore_users_reset_password(array $config, string $accessToken, string $userId, array $payload): array +{ + return omsorgcore_request($config, 'POST', '/api/users/' . urlencode($userId) . '/reset-password', $payload, $accessToken); +} + +// --- Roles --- + +function omsorgcore_roles_list(array $config, string $accessToken): array +{ + return omsorgcore_request($config, 'GET', '/api/roles', null, $accessToken); +} + +function omsorgcore_roles_create(array $config, string $accessToken, string $name): array +{ + return omsorgcore_request($config, 'POST', '/api/roles', ['name' => $name], $accessToken); +} + +// --- Employees (für die Datenmigration gebraucht - Employee je PHP-User finden/anlegen) --- + +function omsorgcore_employees_list(array $config, string $accessToken, int $page = 1, int $pageSize = 100): array +{ + return omsorgcore_request($config, 'GET', '/api/employees?page=' . $page . '&pageSize=' . $pageSize, null, $accessToken); +} + +function omsorgcore_employees_get(array $config, string $accessToken, string $employeeId): array +{ + return omsorgcore_request($config, 'GET', '/api/employees/' . urlencode($employeeId), null, $accessToken); +} + +function omsorgcore_employees_create(array $config, string $accessToken, array $payload): array +{ + return omsorgcore_request($config, 'POST', '/api/employees', $payload, $accessToken); +} + +function omsorgcore_employees_update(array $config, string $accessToken, string $employeeId, array $payload): array +{ + return omsorgcore_request($config, 'PUT', '/api/employees/' . urlencode($employeeId), $payload, $accessToken); +} diff --git a/mitarbeiter-app/lib/upload.php b/omsorgWeb/mitarbeiter-app-legacy/lib/upload.php similarity index 100% rename from mitarbeiter-app/lib/upload.php rename to omsorgWeb/mitarbeiter-app-legacy/lib/upload.php diff --git a/mitarbeiter-app/manifest.webmanifest b/omsorgWeb/mitarbeiter-app-legacy/manifest.webmanifest similarity index 100% rename from mitarbeiter-app/manifest.webmanifest rename to omsorgWeb/mitarbeiter-app-legacy/manifest.webmanifest diff --git a/mitarbeiter-app/migrations/.htaccess b/omsorgWeb/mitarbeiter-app-legacy/migrations/.htaccess similarity index 100% rename from mitarbeiter-app/migrations/.htaccess rename to omsorgWeb/mitarbeiter-app-legacy/migrations/.htaccess diff --git a/mitarbeiter-app/migrations/001_initial_schema.php b/omsorgWeb/mitarbeiter-app-legacy/migrations/001_initial_schema.php similarity index 100% rename from mitarbeiter-app/migrations/001_initial_schema.php rename to omsorgWeb/mitarbeiter-app-legacy/migrations/001_initial_schema.php diff --git a/mitarbeiter-app/migrations/002_avatar_column.php b/omsorgWeb/mitarbeiter-app-legacy/migrations/002_avatar_column.php similarity index 100% rename from mitarbeiter-app/migrations/002_avatar_column.php rename to omsorgWeb/mitarbeiter-app-legacy/migrations/002_avatar_column.php diff --git a/mitarbeiter-app/migrations/003_einsatzanweisung.php b/omsorgWeb/mitarbeiter-app-legacy/migrations/003_einsatzanweisung.php similarity index 100% rename from mitarbeiter-app/migrations/003_einsatzanweisung.php rename to omsorgWeb/mitarbeiter-app-legacy/migrations/003_einsatzanweisung.php diff --git a/mitarbeiter-app/migrations/004_login_attempts.php b/omsorgWeb/mitarbeiter-app-legacy/migrations/004_login_attempts.php similarity index 100% rename from mitarbeiter-app/migrations/004_login_attempts.php rename to omsorgWeb/mitarbeiter-app-legacy/migrations/004_login_attempts.php diff --git a/omsorgWeb/mitarbeiter-app-legacy/migrations/005_urlaubsantrag_anhang.php b/omsorgWeb/mitarbeiter-app-legacy/migrations/005_urlaubsantrag_anhang.php new file mode 100644 index 0000000..5919c54 --- /dev/null +++ b/omsorgWeb/mitarbeiter-app-legacy/migrations/005_urlaubsantrag_anhang.php @@ -0,0 +1,8 @@ + 'Add filename/original_name to requests_urlaubsantrag', + 'up' => [ + "ALTER TABLE requests_urlaubsantrag ADD COLUMN filename VARCHAR(255) NOT NULL DEFAULT ''", + "ALTER TABLE requests_urlaubsantrag ADD COLUMN original_name VARCHAR(255) NOT NULL DEFAULT ''", + ], +]; diff --git a/omsorgWeb/mitarbeiter-app-legacy/migrations/006_omsorgcore_id_mapping.php b/omsorgWeb/mitarbeiter-app-legacy/migrations/006_omsorgcore_id_mapping.php new file mode 100644 index 0000000..0b075c4 --- /dev/null +++ b/omsorgWeb/mitarbeiter-app-legacy/migrations/006_omsorgcore_id_mapping.php @@ -0,0 +1,15 @@ + 'Add omsorgCore identity mapping columns to users (prep for migrate-users-to-core.php)', + 'up' => [ + 'ALTER TABLE users ADD COLUMN omsorgcore_user_id CHAR(36) DEFAULT NULL', + 'ALTER TABLE users ADD COLUMN omsorgcore_employee_id CHAR(36) DEFAULT NULL', + ], +]; diff --git a/mitarbeiter-app/pages/abwesenheitsantrag.php b/omsorgWeb/mitarbeiter-app-legacy/pages/abwesenheitsantrag.php similarity index 100% rename from mitarbeiter-app/pages/abwesenheitsantrag.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/abwesenheitsantrag.php diff --git a/mitarbeiter-app/pages/admin-dienstplan.php b/omsorgWeb/mitarbeiter-app-legacy/pages/admin-dienstplan.php similarity index 100% rename from mitarbeiter-app/pages/admin-dienstplan.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/admin-dienstplan.php diff --git a/mitarbeiter-app/pages/admin.php b/omsorgWeb/mitarbeiter-app-legacy/pages/admin.php similarity index 100% rename from mitarbeiter-app/pages/admin.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/admin.php diff --git a/mitarbeiter-app/pages/benefitsantrag.php b/omsorgWeb/mitarbeiter-app-legacy/pages/benefitsantrag.php similarity index 100% rename from mitarbeiter-app/pages/benefitsantrag.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/benefitsantrag.php diff --git a/omsorgWeb/mitarbeiter-app-legacy/pages/change-password.php b/omsorgWeb/mitarbeiter-app-legacy/pages/change-password.php new file mode 100644 index 0000000..dc1eafc --- /dev/null +++ b/omsorgWeb/mitarbeiter-app-legacy/pages/change-password.php @@ -0,0 +1,75 @@ + + + + + + +OMSORG Felix – Passwort ändern + + + + + + + + diff --git a/mitarbeiter-app/pages/dashboard.php b/omsorgWeb/mitarbeiter-app-legacy/pages/dashboard.php similarity index 100% rename from mitarbeiter-app/pages/dashboard.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/dashboard.php diff --git a/mitarbeiter-app/pages/dienstplan.php b/omsorgWeb/mitarbeiter-app-legacy/pages/dienstplan.php similarity index 100% rename from mitarbeiter-app/pages/dienstplan.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/dienstplan.php diff --git a/mitarbeiter-app/pages/dokument-serve.php b/omsorgWeb/mitarbeiter-app-legacy/pages/dokument-serve.php similarity index 100% rename from mitarbeiter-app/pages/dokument-serve.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/dokument-serve.php diff --git a/mitarbeiter-app/pages/dokumentenarchiv.php b/omsorgWeb/mitarbeiter-app-legacy/pages/dokumentenarchiv.php similarity index 100% rename from mitarbeiter-app/pages/dokumentenarchiv.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/dokumentenarchiv.php diff --git a/mitarbeiter-app/pages/download-serve.php b/omsorgWeb/mitarbeiter-app-legacy/pages/download-serve.php similarity index 100% rename from mitarbeiter-app/pages/download-serve.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/download-serve.php diff --git a/mitarbeiter-app/pages/downloads.php b/omsorgWeb/mitarbeiter-app-legacy/pages/downloads.php similarity index 100% rename from mitarbeiter-app/pages/downloads.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/downloads.php diff --git a/mitarbeiter-app/pages/einsatzanweisung-serve.php b/omsorgWeb/mitarbeiter-app-legacy/pages/einsatzanweisung-serve.php similarity index 100% rename from mitarbeiter-app/pages/einsatzanweisung-serve.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/einsatzanweisung-serve.php diff --git a/mitarbeiter-app/pages/einsatzanweisung.php b/omsorgWeb/mitarbeiter-app-legacy/pages/einsatzanweisung.php similarity index 100% rename from mitarbeiter-app/pages/einsatzanweisung.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/einsatzanweisung.php diff --git a/mitarbeiter-app/pages/einsatzbewertung.php b/omsorgWeb/mitarbeiter-app-legacy/pages/einsatzbewertung.php similarity index 100% rename from mitarbeiter-app/pages/einsatzbewertung.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/einsatzbewertung.php diff --git a/omsorgWeb/mitarbeiter-app-legacy/pages/forgot-password.php b/omsorgWeb/mitarbeiter-app-legacy/pages/forgot-password.php new file mode 100644 index 0000000..5393eac --- /dev/null +++ b/omsorgWeb/mitarbeiter-app-legacy/pages/forgot-password.php @@ -0,0 +1,125 @@ + + + + + + +OMSORG Felix – Passwort vergessen + + + + + + + + diff --git a/mitarbeiter-app/pages/fortbildung-serve.php b/omsorgWeb/mitarbeiter-app-legacy/pages/fortbildung-serve.php similarity index 100% rename from mitarbeiter-app/pages/fortbildung-serve.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/fortbildung-serve.php diff --git a/mitarbeiter-app/pages/fortbildungsantrag.php b/omsorgWeb/mitarbeiter-app-legacy/pages/fortbildungsantrag.php similarity index 100% rename from mitarbeiter-app/pages/fortbildungsantrag.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/fortbildungsantrag.php diff --git a/mitarbeiter-app/pages/settings.php b/omsorgWeb/mitarbeiter-app-legacy/pages/settings.php similarity index 100% rename from mitarbeiter-app/pages/settings.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/settings.php diff --git a/mitarbeiter-app/pages/stundennachweis.php b/omsorgWeb/mitarbeiter-app-legacy/pages/stundennachweis.php similarity index 100% rename from mitarbeiter-app/pages/stundennachweis.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/stundennachweis.php diff --git a/mitarbeiter-app/pages/upload-serve.php b/omsorgWeb/mitarbeiter-app-legacy/pages/upload-serve.php similarity index 100% rename from mitarbeiter-app/pages/upload-serve.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/upload-serve.php diff --git a/mitarbeiter-app/pages/urlaubsantrag.php b/omsorgWeb/mitarbeiter-app-legacy/pages/urlaubsantrag.php similarity index 96% rename from mitarbeiter-app/pages/urlaubsantrag.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/urlaubsantrag.php index dfa80f3..e9eae4e 100644 --- a/mitarbeiter-app/pages/urlaubsantrag.php +++ b/omsorgWeb/mitarbeiter-app-legacy/pages/urlaubsantrag.php @@ -96,7 +96,7 @@ layout_start('OMSORG – Urlaubsantrag', 'urlaubsantrag');

Neuer Urlaubsantrag

-
+ +
diff --git a/mitarbeiter-app/pages/werben.php b/omsorgWeb/mitarbeiter-app-legacy/pages/werben.php similarity index 100% rename from mitarbeiter-app/pages/werben.php rename to omsorgWeb/mitarbeiter-app-legacy/pages/werben.php diff --git a/mitarbeiter-app/service-worker.js b/omsorgWeb/mitarbeiter-app-legacy/service-worker.js similarity index 100% rename from mitarbeiter-app/service-worker.js rename to omsorgWeb/mitarbeiter-app-legacy/service-worker.js diff --git a/mitarbeiter-app/setup.php b/omsorgWeb/mitarbeiter-app-legacy/setup.php similarity index 100% rename from mitarbeiter-app/setup.php rename to omsorgWeb/mitarbeiter-app-legacy/setup.php diff --git a/send-form.php b/omsorgWeb/send-form.php similarity index 100% rename from send-form.php rename to omsorgWeb/send-form.php diff --git a/send-status-template.php b/omsorgWeb/send-status-template.php similarity index 100% rename from send-status-template.php rename to omsorgWeb/send-status-template.php diff --git a/style.css b/omsorgWeb/style.css similarity index 100% rename from style.css rename to omsorgWeb/style.css diff --git a/omsorgapp/.gitignore b/omsorgapp/.gitignore new file mode 100644 index 0000000..ab18c46 --- /dev/null +++ b/omsorgapp/.gitignore @@ -0,0 +1,13 @@ +.DS_Store + +.vscode/ +.idea/ + +node_modules/ +dist/ + +__pycache__/ +*.pyc +.venv/ + +.env diff --git a/omsorgapp/CLAUDE.md b/omsorgapp/CLAUDE.md new file mode 100644 index 0000000..0b86608 --- /dev/null +++ b/omsorgapp/CLAUDE.md @@ -0,0 +1,46 @@ +# CLAUDE.md — omsorgapp (OMSORG Desktop) + +Gilt zusätzlich zur Root-`CLAUDE.md`. Dies ist das Electron/React-Desktop-Projekt für Büromitarbeiter (Sabina, Malik, Sabrina, Sascha). + +## Stack & Struktur + +- **Electron** (Hauptprozess) + **React 18** + **Vite** (Renderer), reines JSX (kein TS trotz `typescript`-Dependency — bisher ungenutzt). +- `electron/main.cjs` — Electron-Hauptprozess. Legt beim Start `~/Documents/Omsorg Business Controls Pro/` mit Unterordnern (`database/`, `documents/employees`, `documents/customers`, `backups/`) an. Aktuell einzige lokale Datenhaltung (für die Fachmodule, nicht für Auth): **eine lokale JSON-Datei** (`database/omsorg-local-db.json`), gelesen/geschrieben über IPC-Handler (`db:get`, `db:set`, `app:paths`, `app:openRoot`, `backup:create`). +- `electron/backend/` — Adapterschicht zu `omsorgCore` (HTTP), ausschließlich im Main-Prozess genutzt: `config.cjs` (einzige Stelle mit der Backend-URL, Default `http://localhost:5245`, überschreibbar per `OMSORG_CORE_URL`), `httpClient.cjs` (einzige Stelle mit `fetch`/Auth-Header), `authClient.cjs` (`login`/`refresh`/`logout` gegen `/api/auth/*`), `employeesClient.cjs` (`list`/`get`/`create`/`update` gegen `/api/employees`, kein `delete` da der Endpunkt in `omsorgCore` fehlt). Für künftige Ressourcen (Einrichtungen, ...) entsteht nach demselben Muster je eine eigene `Client.cjs`-Datei, analog zu den Controllern in `omsorgCore` — siehe `main.cjs`s `api:get`/`api:post`-Proxy für Ressourcen ohne eigene Client-Datei. +- **Login + Session:** `main.cjs` hält den Access-Token nur im Speicher, verschlüsselt den Refresh-Token via `safeStorage` und speichert ihn unter `app.getPath('userData')/session.enc`. Silent Refresh läuft per Timer (kurz vor Ablauf) und reaktiv bei 401 (z. B. nach Laptop-Standby). IPC-Handler: `auth:login`, `auth:logout`, `auth:getSession`, `api:get`/`api:post` (authentifizierter Proxy). Kein Token verlässt je den Main-Prozess in Richtung Renderer. +- `electron/preload.cjs` — exponiert `window.omsorg` (contextBridge) mit `getDb`, `setDb`, `paths`, `openRoot`, `createBackup`, `auth.{login,logout,getSession,onSessionChanged}`, `api.{get,post}`. Renderer darf nie direkt auf Node/`fs` zugreifen — immer über diese Brücke. +- `src/main.jsx` — Renderer-Einstiegspunkt, wrappt `App` in `AuthProvider` (`src/app/AuthContext.jsx`) und rendert in `#root`. +- `src/app/AuthContext.jsx` — React-Context um `window.omsorg.auth`, stellt `useAuth()` mit `{ isAuthenticated, user, isLoading, login, logout }` bereit. +- `src/app/app.jsx` — gated zuerst auf Auth (`isLoading` → Ladehinweis, `!isAuthenticated` → `src/modules/auth/LoginPage.jsx`), danach Top-Level-Router: hält `activePage` als lokalen State (kein Routing-Framework), switcht zwischen Modulen. Noch nicht implementierte Module rendern `PlaceholderPage`. +- `src/layouts/AppLayout.jsx` — Grundlayout: `Sidebar` + `Header` + `main.page-content`. +- `src/components/` — geteilte UI: `Sidebar`, `Header`, `OmsorgCard`, und `components/ui/` (`OmsorgButton`, `OmsorgBadge`, `OmsorgStatCard`). +- `src/modules//` — ein Ordner pro Fachmodul, z. B. `modules/home/` (HomePage, HomeStats, ContractWidget), `modules/employees/` (EmployeesPage, EmployeeDetailPanel, EmployeeTabs), `modules/debug/` (`DebugSessionsPage.jsx`). Neue Module folgen diesem Muster: eigener Ordner unter `src/modules/`, Einstiegskomponente `Page.jsx`. +- **Debug-Sicht (`modules/debug/DebugSessionsPage.jsx`):** listet aktive Sessions (`GET /api/admin/sessions` über den generischen `api:get`-Proxy), erlaubt Einzel- und Gesamt-Widerruf (`POST /api/admin/sessions/{id}/revoke`, `POST /api/admin/sessions/revoke-all`) — invalidiert serverseitig sofort alle betroffenen Access-Tokens (Details: `omsorgCore/CLAUDE.md` Abschnitt "Session-Killswitch"). Im `Sidebar.jsx`-Menü nur sichtbar, wenn `useAuth().hasPermission("UserManagement", "View")` — dieselbe granulare Rechteprüfung, die serverseitig über `RequirePermission(ModuleType.UserManagement, ...)` auf `AdminSessionsController` erzwungen wird (kein Rollennamen-Vergleich mehr, siehe "Rechtesystem im Client" unten). +- **Rechtesystem im Client:** Rechte kommen nicht aus dem JWT (das trägt nur `sub`/`name`/`role`/`sst`). `main.cjs` lädt nach jedem Login/Refresh zusätzlich `GET /api/auth/me` (`electron/backend/authClient.cjs#me`) und ersetzt `session.user` durch `{ username, role, permissions }` — `permissions` ist die vom Backend aufgelöste Liste aus `PermissionService.GetGrantedPermissionsAsync` (Rollen-Default + Overrides, je `{ module, action }` als String). `AuthContext.jsx` stellt darauf `hasPermission(module, action)` bereit; UI-Komponenten prüfen darüber, nie über `user.role` direkt. +- **Verbindliche Regel — UI folgt den Rechten, für jedes Modul:** Jeder Sidebar-Tab und jede Aktion (Anlegen/Bearbeiten/Löschen/...) muss über `hasPermission(module, action)` gegated werden — das ist kein Sonderfall für Mitarbeiter, sondern das Standardmuster für jedes neue Modul (mindestens `View` fürs Sichtbarsein des Tabs, `Create`/`Edit`/... für einzelne Aktionen darin). Referenzimplementierung: `src/modules/employees/EmployeesPage.jsx` (`canCreate = hasPermission("Employees", "Create")`) und `src/modules/employees/EmployeeDetailPanel.jsx` (`canEdit = hasPermission("Employees", "Edit")`). + - Die Zuordnung Sidebar-Tab → `ModuleType` steht zentral in `src/app/navPermissions.js` (`NAV_MODULES`) und wird sowohl von `Sidebar.jsx` (blendet nicht erlaubte Tabs aus) als auch von `app.jsx` (fällt auf `"Home"` zurück, falls der aktive Tab durch eine Rechteänderung nicht mehr erlaubt ist) genutzt — neue Zuordnungen nur dort eintragen, nicht duplizieren. + - Aktuelles Mapping: Mitarbeiter→`Employees`, Kunden→`Facilities`, Disposition→`Orders`, Rechnungen→`Invoices`, Controlling→`Controlling`, Einstellungen→`UserManagement`, Debug→`UserManagement`. Home hat kein Modul und ist immer sichtbar. Kalkulation und Fahrzeuge sind reine `PlaceholderPage`-Stubs ohne Fachlogik und haben (noch) kein passendes `ModuleType` — bewusst ungegated, bis ein echtes Modul dahintersteht; dann Eintrag in `navPermissions.js` ergänzen (ggf. mit neuem `ModuleType`-Wert in `omsorgCore`, additiv, keine Migration nötig). +- `src/style.css` — einziges Stylesheet, kein CSS-Framework (`style.backup.css` ist eine Sicherungskopie, keine aktive Datei). + +## Wichtige Startbefehle + +```bash +npm install +npm run dev # Vite-Devserver + Electron parallel (concurrently/wait-on) +npm run web # nur Vite, im Browser statt Electron +npm run start # nur Electron (erwartet gebauten dist/) +``` + +## Aktueller Ist-Stand (siehe auch `REQUIREMENTS.md`) + +- Vollständig: `HomePage`, `EmployeesPage` (Grundgerüst; Daten kommen über `electron/backend/employeesClient.cjs` echt aus `omsorgCore`, keine hartcodierten Beispieldaten mehr für dieses Modul), Login-Screen + persistente Session gegen `omsorgCore`. +- Alle anderen Menüpunkte (Kunden, Disposition, Kalkulation, Fahrzeuge, Rechnungen, Controlling, Einstellungen) sind reine `PlaceholderPage`-Platzhalter in `app.jsx`. +- **Auth und Mitarbeiter sprechen bereits gegen `omsorgCore`** (siehe `employeesClient.cjs`, IPC-Handler `employees:list/create/update` in `main.cjs`), die übrigen Fachmodule (Kunden, ...) noch nicht — die lokale JSON-Datei bleibt für diese vorerst die Datenquelle (Übergangslösung, README: "SQLite wird später sauber über eine entkoppelte Persistenzschicht eingebaut"). Nächster Schritt: weitere Fachmodule nach und nach auf `electron/backend/Client.cjs` + `omsorgCore`-Endpunkte umstellen, analog zum Mitarbeiter-/Auth-Vorbild. +- Keine Verbindung zu `omsorgWeb`/MySQL — Mitarbeiterdaten hier sind komplett getrennt von denen in OMSORG Connect. Nicht durch neue Kopplungen/Workarounds "beheben"; die eigentliche Lösung ist die gemeinsame Datenbasis in `omsorgCore`. + +## Konventionen + +- Neue Module: Ordner `src/modules//Page.jsx` + Unterkomponenten, in `app.jsx`-Switch eintragen, in `Sidebar.jsx` verlinken. +- UI-Bausteine aus `src/components/ui/` wiederverwenden statt neue Button/Badge/Card-Varianten zu bauen. +- Jeglicher Dateisystem-/Datenzugriff nur über die `window.omsorg`-Brücke aus `preload.cjs`, nie direkt im Renderer. +- **Neue `omsorgCore`-Endpunkte:** pro Endpunkt-Kategorie eine eigene Datei `electron/backend/Client.cjs` (z. B. künftig `employeesClient.cjs` für den `EmployeesController`), die ausschließlich `httpClient.request(...)` nutzt und Routen/Feldnamen genau dieser einen Ressource kapselt — spiegelt die Controller-Aufteilung in `omsorgCore` 1:1. `main.cjs`/IPC-Handler importieren nur diese Client-Dateien, nie `httpClient.cjs` direkt und nie rohe URLs. Solange eine Ressource noch keine eigene Client-Datei hat, läuft sie über den generischen `api:get`/`api:post`-Proxy in `main.cjs`; sobald die Client-Datei existiert, bekommt sie eigene IPC-Kanäle nach demselben Muster wie `auth:*`. Grund: Backend soll austauschbar bleiben, ohne `preload.cjs` oder den React-Teil anzufassen (siehe Adapterschicht oben). diff --git a/omsorgapp/README.md b/omsorgapp/README.md new file mode 100644 index 0000000..d9c13a0 --- /dev/null +++ b/omsorgapp/README.md @@ -0,0 +1,24 @@ +# Omsorg Business Controls Pro – Release 0.1.1 Foundation + +Stabile lokale Electron/React-Basis ohne native SQLite-Abhängigkeit. + +## Warum 0.1.1? +Release 0.1.0 nutzte `better-sqlite3`. Auf macOS/Electron kann dieses native Modul Versionsprobleme verursachen. +Release 0.1.1 speichert Daten zuerst in einer lokalen JSON-Datenbank im Benutzerordner. + +## Start +```bash +cd ~/Desktop/omsorg-business-controls-pro-release-0.1.1 +npm install +npm run dev +``` + +## Lokale Daten +Die App legt Ordner unter `~/Documents/Omsorg Business Controls Pro` an: +- database/omsorg-local-db.json +- documents/employees +- documents/customers +- backups + +## Nächster Sprint +SQLite wird später sauber über eine entkoppelte Persistenzschicht eingebaut. diff --git a/omsorgapp/docs/OMSORG-BLUEPRINT.md b/omsorgapp/docs/OMSORG-BLUEPRINT.md new file mode 100644 index 0000000..2c6742a --- /dev/null +++ b/omsorgapp/docs/OMSORG-BLUEPRINT.md @@ -0,0 +1,1361 @@ +# OMSORG Business Controls Pro + +## Das digitale Herzstück von Omsorg + +> Menschen zuerst. +> Prozesse automatisch. +> Daten einmal erfassen. +> Jeden Arbeitstag einfacher machen. + +--- + +## Dokumentstatus + +- Dokument: OMSORG Blueprint +- Version: 1.0 +- Status: Verbindliche Arbeitsgrundlage +- Unternehmen: Omsorg Dautovic & Neumann GbR +- Hauptprodukt: OMSORG Business Controls Pro +- Mobile Ergänzung: OMSORG Connect + +--- + +# 1. Vision + +OMSORG Business Controls Pro ist keine klassische Verwaltungssoftware. + +Die Plattform soll alle zentralen Arbeitsabläufe der Pflege-Zeitarbeit in einem gemeinsamen System verbinden und den täglichen Arbeitsaufwand von Omsorg deutlich reduzieren. + +Die Software verwaltet nicht nur Daten. Sie unterstützt die Benutzer aktiv, erkennt Handlungsbedarf, erinnert an Fristen und automatisiert wiederkehrende Aufgaben. + +Das System wird zuerst und vollständig für die tatsächlichen Arbeitsabläufe von Omsorg entwickelt. + +Das bestehende Grundgerüst der Desktop-Anwendung bleibt erhalten und wird schrittweise zur vollständigen Unternehmensplattform erweitert. + +--- + +## 1.1 Unser Ziel + +OMSORG Business Controls Pro soll: + +- Zeit sparen +- doppelte Eingaben verhindern +- Fehler reduzieren +- Fristen automatisch überwachen +- wichtige Aufgaben sichtbar machen +- Mitarbeiter und Einrichtungen miteinander verbinden +- Zeiterfassung und Abrechnung automatisieren +- Entscheidungen durch klare Auswertungen erleichtern +- Desktop-Software und OMSORG Connect verbinden +- sensible Daten zuverlässig schützen + +Die Software soll sich so natürlich anfühlen, als wäre sie direkt aus dem Arbeitsalltag von Omsorg entstanden. + +--- + +## 1.2 Die Omsorg-DNA + +Die Entwicklung folgt fünf Grundsätzen: + +1. Menschen zuerst +2. Prozesse automatisch +3. Daten nur einmal erfassen +4. So wenig Klicks wie möglich +5. Sicherheit und Vertrauen als Grundlage + +--- + +## 1.3 Das Omsorg-Prinzip + +Vor der Umsetzung einer Funktion werden sieben Fragen gestellt: + +1. Hilft diese Funktion einem Menschen? +2. Spart sie Zeit? +3. Verhindert sie Fehler? +4. Vermeidet sie doppelte Eingaben? +5. Ist sie verständlich und logisch? +6. Ist sie sicher und später erweiterbar? +7. Passt sie zur DNA von Omsorg? + +Eine Funktion wird nur umgesetzt, wenn sie einen klaren Nutzen für Omsorg besitzt. + +--- + +# 2. Der tatsächliche Arbeitsalltag + +Die Software orientiert sich nicht zuerst an Menüs. + +Sie orientiert sich am tatsächlichen Tagesablauf von Omsorg. + +Der Arbeitstag beginnt mit den Menschen. Erst danach folgen Prozesse, Rechnungen und Verwaltung. + +--- + +## 2.1 Arbeitsbeginn und Morning Briefing + +Der Bürotag beginnt mit: + +1. Outlook und neuen E-Mails +2. Krankmeldungen +3. Anliegen der Mitarbeiter +4. Disposition +5. fehlenden Tätigkeitsnachweisen +6. offenen und neuen Rechnungen +7. Zahlungseingängen +8. Recruiting und Kaltaquise +9. Marketing und Werbung + +Das Dashboard soll diese Reihenfolge widerspiegeln. + +Beim Öffnen der Software werden die wichtigsten Informationen des Tages angezeigt. + +Beispiele: + +- neue Krankmeldung +- Mitarbeiter benötigt einen Rückruf +- fehlender Tätigkeitsnachweis +- Einsatz muss neu besetzt werden +- neue Anfrage einer Einrichtung +- Rechnung kann erstellt werden +- Rechnung ist überfällig +- Akquise-Wiedervorlage ist heute fällig +- neuer Mitarbeiter beginnt demnächst + +--- + +## 2.2 Montag bei Omsorg + +Ein typischer Montag besteht aus: + +1. Krankmeldungen und Mitarbeiteranliegen prüfen +2. Disposition bei Ausfällen anpassen +3. Mitarbeiter an Tätigkeitsnachweise der Vorwoche erinnern +4. Outlook vollständig prüfen +5. Anfragen von Einrichtungen bearbeiten +6. eingereichte Tätigkeitsnachweise kontrollieren +7. wöchentliche Rechnungen erstellen und versenden +8. offene Zahlungen prüfen +9. bei Bedarf Mahnungen vorbereiten +10. Mitarbeiter-Recruiting durchführen +11. Einrichtungen telefonisch akquirieren +12. Wiedervorlagen bearbeiten +13. Werbung und Marketing planen +14. zum Tagesabschluss E-Mails, Rechnungen und Nachweise erneut prüfen + +--- + +## 2.3 Tagesabschluss + +Am Ende des Arbeitstages soll die Software anzeigen: + +### Heute erledigt + +- bearbeitete Krankmeldungen +- organisierte Vertretungen +- erstellte Rechnungen +- versendete Mahnungen +- erledigte Akquisegespräche +- bearbeitete Bewerbungen +- veröffentlichte Marketingmaßnahmen + +### Noch offen + +- fehlende Tätigkeitsnachweise +- offene Rückrufe +- unbeantwortete E-Mails +- überfällige Rechnungen +- nicht abgeschlossene Wiedervorlagen +- noch nicht besetzte Einsätze + +--- + +# 3. OMSORG Flow + +OMSORG Flow beschreibt die vollständigen Lebenszyklen des Unternehmens. + +--- + +## 3.1 Einrichtung und Kunde + +```text +Lead +→ erster Kontakt +→ kontaktiert +→ kein Bedarf +→ Wiedervorlage +→ Interesse +→ Termin +→ Angebot +→ Neukunde +→ Bestandskunde +→ Auftrag +→ Betreuung +→ Vertragsverlängerung +→ inaktiv oder archiviert +Bei „kein Bedarf“ wird automatisch eine neue Wiedervorlage angelegt. +Standardintervall: 14 Tage + +3.2 Mitarbeiter +Interessent +→ Bewerbung +→ Erstkontakt +→ Vorstellung +→ Unterlagen +→ Angebot +→ Einstellung +→ geplanter Eintritt +→ Personalakte +→ Einsatz +→ Zeiterfassung +→ Fortbildung +→ Urlaub und Abwesenheit +→ Austritt +→ Archiv + +3.3 Einsatz +Anfrage der Einrichtung +→ Auftrag +→ Anforderungen und Qualifikation +→ Mitarbeiter auswählen +→ Mitarbeiter zuweisen +→ Einsatzanweisung +→ Bestätigung über OMSORG Connect +→ Arbeitszeit +→ Tätigkeitsnachweis +→ Prüfung +→ Freigabe + +3.4 Rechnung +freigegebene Arbeitszeit +→ Konditionen der Einrichtung +→ automatische Berechnung +→ Rechnungsentwurf +→ Prüfung +→ Freigabe +→ PDF +→ Versand +→ Zahlungsziel 14 Tage +→ Zahlung oder Mahnung +→ Zahlung zuordnen +→ Controlling + +3.5 Recruiting und Akquise +Mitarbeiter-Recruiting +Werbung oder Empfehlung +→ Bewerbung +→ Kontakt +→ Gespräch +→ Unterlagen +→ Einstellung +→ geplanter Start +→ aktive Beschäftigung + +Einrichtungsakquise +Einrichtung finden +→ Lead anlegen +→ Ansprechpartner und Durchwahl erfassen +→ Kontakt +→ Gespräch dokumentieren +→ Ergebnis festlegen +→ Wiedervorlage nach 14 Tagen +→ Interesse +→ Angebot +→ Kunde + +4. Plattformarchitektur +OMSORG Business Controls Pro besteht aus vier verbundenen Ebenen. + +4.1 OMSORG Desktop +Desktop-Software für: +Sabina +Malik +Sabrina +Sascha +zukünftige Büromitarbeiter +Sie enthält die vollständigen Unternehmensmodule entsprechend der jeweiligen Berechtigungen. + +4.2 OMSORG Connect +Mobile Anwendung für Außendienstmitarbeiter. +Geplante Funktionen: +eigene Einsätze +Einsatzanweisungen +Einsatzbestätigung +Zeiterfassung +digitaler Tätigkeitsnachweis +Upload unterschriebener Nachweise +digitales Fahrtenbuch +Krankmeldung +Verspätung +Rückrufwunsch +Urlaub und Abwesenheit +Dokumente +News +Benefits +Ansprechpartner +Fortbildungscenter +Außendienstmitarbeiter sehen ausschließlich ihre eigenen Daten. + +4.3 OMSORG Core +OMSORG Core ist die gemeinsame und einzige Datenbasis. +Jede Information wird möglichst nur einmal erfasst. +Beispiele: +Ein Mitarbeiterdatensatz wird gemeinsam verwendet von: +Personalakte +Disposition +Zeiterfassung +Rechnungsprozess +Lohnvorbereitung +Controlling +OMSORG Connect +Ein Einrichtungsdatensatz wird gemeinsam verwendet von: +CRM +Akquise +Ansprechpartnern +Konditionen +Aufträgen +Disposition +Verträgen +Rechnungen +Controlling + +4.4 OMSORG Engine +Die OMSORG Engine verarbeitet Ereignisse und Fristen. +Beispiele: +Vertrag endet bald +Vertragsende erkannt +→ Dashboard-Warnung +→ zuständige Person informieren +→ Telefonnummer und E-Mail anbieten +→ Vertrag direkt öffnen + +Tätigkeitsnachweis fehlt +Nachweis fehlt +→ Mitarbeiter markieren +→ Erinnerung über OMSORG Connect +→ Büro über fehlenden Nachweis informieren + +Rechnung überfällig +Zahlungsziel überschritten +→ Warnung +→ Mahnentwurf +→ Freigabe +→ Versand +→ Mahnhistorie + +Neuer Mitarbeiter beginnt +Eintrittsdatum nähert sich +→ Dashboard-Hinweis +→ Personalakte prüfen +→ Dokumente prüfen +→ App-Zugang vorbereiten +→ Einsatzanweisung kontrollieren + + +5. Module +Die geplanten Hauptmodule sind: +Home und Smart Dashboard +Mitarbeiter +Einrichtungen und CRM +Recruiting +Akquise und Wiedervorlagen +Disposition +Zeiterfassung +Tätigkeitsnachweise +Rechnungen +Zahlungsüberwachung und Mahnungen +Kalkulation +Fahrzeuge +digitales Fahrtenbuch +Dokumente +Marketing +Controlling +OMSORG Connect +Benutzer, Rollen und Berechtigungen +Einstellungen +Integrationen + +6. Rollen und Berechtigungen + +6.1 Geschäftsführung +Sabina +Vollzugriff auf alle Funktionen und Daten. +Malik +Vollzugriff auf alle Funktionen und Daten. +Die Geschäftsführung besitzt dauerhaft Zugriff auf: +vollständige Finanzdaten +Gewinn +Margen +Controlling +Benutzerverwaltung +Berechtigungen +sicherheitsrelevante Einstellungen + +6.2 Disposition und Buchhaltung +Sabrina +Darf unter anderem: +Mitarbeiter verwalten +Mitarbeiterakten einsehen +Einrichtungen verwalten +Ansprechpartner bearbeiten +Konditionen hinterlegen +Aufträge und Einsätze verwalten +Arbeitszeiten prüfen +Rechnungen erstellen +Rechnungen freigeben +Zahlungen zuordnen +Recruiting und CRM nutzen +Sabrina darf nicht den vollständigen Unternehmensgewinn oder vertrauliche Gesamtmargen einsehen. + +Sascha +Darf: +Mitarbeiter-Recruiting nutzen +Bewerber verwalten +Mitarbeiterakten einsehen, soweit erforderlich +Einrichtungen als Leads anlegen +Einrichtungsakquise durchführen +Kontakte und Wiedervorlagen bearbeiten +Marketing und Werbung verwalten +Kein Zugriff auf: +Rechnungen +Zahlungen +Gewinn +Margen +vollständiges Controlling + +6.4 Außendienstmitarbeiter +Außendienstmitarbeiter arbeiten ausschließlich über OMSORG Connect. +Sie dürfen nur die jeweils freigegebenen eigenen Daten sehen und bearbeiten. + +6.5 Zukünftige Büromitarbeiter +Beim Anlegen eines Büromitarbeiters können Rechte individuell eingestellt werden. +Mögliche Rechte: +Modul sehen +Daten lesen +Daten anlegen +Daten bearbeiten +Daten löschen +Daten exportieren +Daten freigeben +Rechnungen sehen +Finanzdaten sehen +Gewinn sehen +Controlling sehen +Benutzer verwalten +Rollen dienen als Vorlage. Individuelle Rechte können zusätzlich vergeben oder entzogen werden. + +7. Mitarbeiter +Die bestehende Mitarbeiterübersicht wird zur vollständigen digitalen Personalakte erweitert. +Geplante Bereiche: +Übersicht +persönliche Daten +Kontaktdaten +Arbeitsvertrag +Dokumente +Qualifikationen +Fortbildungen +Zuschläge +Einsätze +Zeiterfassung +Tätigkeitsnachweise +Urlaub und Abwesenheit +Krankmeldungen +Dienstwagen +Fahrtenbuch +Benefits +Notizen +Historie +App-Zugang +Benutzerrechte bei Büromitarbeitern +Beim Anlegen eines Mitarbeiters wird insbesondere das Eintrittsdatum erfasst. +Dadurch kann das Dashboard automatisch auf bevorstehende Mitarbeiterstarts hinweisen. + +8. Einrichtungen und CRM +Jede Einrichtung erhält eine zentrale Kundenakte. +Geplante Angaben: +Name +Einrichtungsart +allgemeine Adresse +Rechnungsadresse +zentrale Telefonnummer +Durchwahlen +E-Mail-Adressen +Ansprechpartner +Funktionen der Ansprechpartner +Abteilungen oder Wohnbereiche +CRM-Status +Gesprächsnotizen +letzte Kontaktaufnahme +nächste Wiedervorlage +Verträge +Konditionen +Aufträge +zugewiesene Mitarbeiter +Tätigkeitsnachweise +Rechnungen +Zahlungen +Historie + +9. Zeiterfassung und Tätigkeitsnachweise +Die Zeiterfassung orientiert sich funktional an modernen Zeiterfassungssystemen wie Clockodo, wird jedoch vollständig mit den Omsorg-Prozessen verbunden. +Geplante Funktionen: +Arbeitsbeginn +Pause +Arbeitsende +Einrichtung und Einsatz +Schichtart +Nachtzeit +Wochenendzeit +Feiertagszeit +digitale Bestätigung +optional Unterschrift der Einrichtung +Upload als Foto oder PDF +Prüfstatus +Freigabe +Abrechnungsstatus +Status: +offen +→ eingereicht +→ Prüfung +→ Rückfrage +→ freigegeben +→ abgerechnet + +10. Rechnungen und Zahlungen +Rechnungen werden wöchentlich erstellt. +Grundlage: +freigegebene Arbeitszeiten ++ individuelle Konditionen der Einrichtung ++ abrechenbare Zuschläge += Rechnungsentwurf + +Rechnungsstatus: +Entwurf +→ Prüfung +→ freigegeben +→ versendet +→ offen +→ bezahlt + +Alternativ: +offen +→ überfällig +→ Mahnung +→ bezahlt +das reguläre Zahlungsziel beträgt 14 Tage + +11. Controlling +Controlling erhält Daten aus allen relevanten Modulen. +Geplante Auswertungen: +Umsatz +Kosten +Gewinn +Deckungsbeitrag +Margen +Auslastung +Mitarbeiterstunden +Fehlzeiten +Einrichtungsrentabilität +offene Forderungen +Recruiting-Erfolg +Marketing-Erfolg +Fahrzeugkosten +Drei-Monats-Durchschnitt +Trends und Prognosen +Vollständige Gewinn- und Margendaten sind ausschließlich für Sabina und Malik sichtbar. + +12. Outlook und Kommunikation +Outlook bleibt das bestehende E-Mail-System. +OMSORG Business Controls Pro soll Outlook zukünftig in die Arbeitsabläufe integrieren. +Geplante Funktionen: +neue E-Mails im Morning Briefing anzeigen +E-Mail einer Einrichtung zuordnen +E-Mail einem Mitarbeiter zuordnen +aus einer E-Mail einen Auftrag erstellen +aus einer E-Mail eine Aufgabe erstellen +aus einer Kundenakte eine E-Mail vorbereiten +Rechnungsversand dokumentieren +Kommunikationshistorie anzeigen +Outlook wird nicht ersetzt. Die Inhalte werden innerhalb der Omsorg-Prozesse nutzbar gemacht. + + +13. Datenschutz und Datensicherheit +Datenschutz und Datensicherheit sind grundlegende Bestandteile der Architektur. +Verbindliche Grundsätze: +DSGVO-konforme Verarbeitung +minimale notwendige Berechtigungen +getrennte Benutzerkonten +sichere Anmeldung +verschlüsselte Datenübertragung +verschlüsselte Speicherung sensibler Daten +besonders geschützte Dokumente +automatische Backups +regelmäßige Wiederherstellungsprüfung +Sitzungssperre +nachvollziehbare Änderungshistorie +Protokollierung sicherheitsrelevanter Vorgänge +Aufbewahrungs- und Löschkonzept +Schutz vor unbefugtem Export +Mitarbeiterzugriff ausschließlich auf eigene Daten +Berechtigungsprüfung auf Oberfläche und Datenebene +Datenschutz ist keine spätere Zusatzfunktion. +Datenschutz ist die Grundlage des Systems. + +14. White-Label-Architektur +OMSORG Business Controls Pro wird zuerst und vollständig für Omsorg entwickelt. +Der technische Kern soll dennoch so aufgebaut werden, dass die Plattform später bei Bedarf auch anderen Unternehmen angeboten werden kann. +Geschäftslogik und Branding werden getrennt. +Austauschbare Bestandteile: +Firmenname +Logo +Farben +Schriftarten +Bezeichnungen +Startbildschirm +App-Branding +E-Mail-Absender +Rechnungsvorlagen +Dokumentvorlagen +Die Omsorg-Ausführung bleibt die Referenz und das Herzstück der Entwicklung. +Für eine spätere Nutzung durch andere Unternehmen ist eine vollständige Mandantentrennung erforderlich. +Daten verschiedener Unternehmen dürfen niemals vermischt werden. + +15. Entwicklungsprinzipien +Die Entwicklung erfolgt in kleinen, überprüfbaren Schritten. +Arbeitsweise: +Entscheidung im Blueprint prüfen +eine Datei oder Funktion bearbeiten +Anwendung testen +Fehler beheben +Git-Commit erstellen +nächsten Schritt beginnen +Technische Grundsätze: +keine mehrfach gepflegten Daten +keine isolierten Insellösungen +wiederverwendbare Komponenten +modulare Architektur +klare Verantwortlichkeiten +Rechte zentral prüfen +Sicherheit von Anfang an +Design und Branding vom Kern trennen +bestehende Anwendung schrittweise erweitern + +16. Entwicklungsroadmap +Phase 1 – Fundament +Blueprint +zentrale Datenstruktur +Rollen und Berechtigungen +sichere lokale Speicherung +Änderungsprotokoll +Backup-Grundlagen +Design- und Branding-Trennung +Phase 2 – Stammdaten +Mitarbeiter vollständig anlegen und bearbeiten +Einrichtungen vollständig anlegen und bearbeiten +Ansprechpartner +Rechnungsadressen +Verträge +Konditionen +Dokumente +Phase 3 – Operatives Geschäft +Aufträge +Disposition +Mitarbeiterzuweisung +Einsatzanweisungen +Krankmeldungen +Ersatzbesetzung +Phase 4 – Zeit und Abrechnung +Zeiterfassung +Tätigkeitsnachweise +Freigabeprozess +automatische Rechnungsentwürfe +Rechnungsversand +Zahlungen +Mahnungen +Phase 5 – Recruiting und CRM +Bewerberpipeline +Kundenakquise +14-Tage-Wiedervorlagen +Gesprächshistorie +Marketing +Phase 6 – OMSORG Connect +Anmeldung +eigene Einsätze +Einsatzanweisungen +Zeiterfassung +Tätigkeitsnachweise +Mitarbeiteranliegen +Krankmeldungen +digitales Fahrtenbuch +Urlaub +Dokumente +News und Benefits +Phase 7 – Dashboard und Controlling +Morning Briefing +Live Dashboard +automatische Erinnerungen +Drei-Monats-Durchschnitt +Trends und Prognosen +vollständige Auswertungen +Outlook-Integration + +17. Verbindlicher Leitsatz +Wir entwickeln keine Software, um Daten zu verwalten. +Wir entwickeln ein System, das Menschen unterstützt, Zeit spart und Omsorg jeden Tag ein Stück besser macht. + +18. Änderungsprotokoll +Blueprint 1.0 +Erste verbindliche Fassung mit: +Vision +Omsorg-DNA +täglichem Arbeitsablauf +OMSORG Flow +Plattformarchitektur +Rollen und Berechtigungen +Hauptmodulen +Datenschutz und Datensicherheit +White-Label-Ziel +Entwicklungsroadmap + +```text + +--- + +# 19. OMSORG Core + +OMSORG Core beschreibt die zentralen fachlichen Bausteine der gesamten Plattform. + +Alle Module, Automatisierungen, Dashboards und Auswertungen greifen auf dieselben gemeinsamen Daten zurück. + +Die wichtigste Regel lautet: + +> Jede Information wird möglichst nur einmal erfasst und anschließend überall dort verwendet, wo sie benötigt wird. + +Die sechs zentralen Objekte sind: + +1. Mitarbeiter +2. Einrichtung +3. Vertrag +4. Auftrag +5. Zeiterfassung +6. Rechnung + +Diese Objekte bilden gemeinsam das fachliche Herz von OMSORG Business Controls Pro. + +--- + +## 19.1 Mitarbeiter + +Der Mitarbeiterdatensatz bildet die zentrale digitale Personalakte. + +Er enthält alle Informationen, die für Beschäftigung, Einsatzplanung, Zeiterfassung, Abrechnung, Kommunikation und OMSORG Connect benötigt werden. + +### Stammdaten + +- Vorname +- Nachname +- Geburtsdatum +- Adresse +- Telefonnummer +- E-Mail-Adresse +- Notfallkontakt +- Beschäftigungsart +- Qualifikation +- Eintrittsdatum +- Austrittsdatum +- Status + +### Arbeitsvertrag + +- Vertragsbeginn +- Vertragsende +- vereinbarte Wochen- oder Monatsarbeitszeit +- Stundenlohn +- Nachtzuschlag +- Samstagszuschlag +- Sonntagszuschlag +- Feiertagszuschlag +- Überstundenregelung +- Urlaubsanspruch +- Probezeit + +### Personalakte + +- Dokumente +- Verträge +- Qualifikationen +- Fortbildungen +- Führerschein +- Gesundheitsnachweise +- Notizen +- Historie +- Erinnerungen und Fristen + +### Einsatzdaten + +- aktuelle Einsätze +- frühere Einsätze +- zugewiesene Einrichtungen +- Schichten +- Einsatzanweisungen +- Verfügbarkeit +- Abwesenheiten +- Krankmeldungen + +### OMSORG Connect + +- App-Zugang +- eigene Einsätze +- Zeiterfassung +- Tätigkeitsnachweise +- Fahrtenbuch +- Dokumentenupload +- Urlaub und Abwesenheit +- Mitarbeiteranliegen +- News und Benefits + +### Benutzerrechte + +Bei Büromitarbeitern kann der Mitarbeiterdatensatz zusätzlich mit einem Benutzerkonto verbunden werden. + +Dabei werden Rolle und individuelle Rechte festgelegt. + +--- + +## 19.2 Einrichtung + +Die Einrichtung ist der zentrale Kunden- und CRM-Datensatz. + +Sie verbindet Akquise, Ansprechpartner, Konditionen, Verträge, Aufträge, Mitarbeiterzuweisungen und Rechnungen. + +### Stammdaten + +- Name +- Einrichtungsart +- allgemeine Adresse +- Rechnungsadresse +- zentrale Telefonnummer +- zentrale E-Mail-Adresse +- Website +- Status + +### Ansprechpartner + +Eine Einrichtung kann mehrere Ansprechpartner besitzen. + +Je Ansprechpartner werden erfasst: + +- Name +- Funktion +- Abteilung oder Wohnbereich +- Telefonnummer +- Durchwahl +- Mobilnummer +- E-Mail-Adresse +- bevorzugter Kontaktweg +- Notizen + +### CRM + +- Lead-Status +- Neukunde oder Bestandskunde +- kein Bedarf +- letzter Kontakt +- nächster Kontakt +- Wiedervorlage +- Gesprächsnotizen +- Angebote +- Historie + +### Konditionen + +- Verrechnungssatz +- Qualifikationsabhängige Preise +- Nachtzuschlag +- Samstagszuschlag +- Sonntagszuschlag +- Feiertagszuschlag +- Fahrtkosten +- Mindeststunden +- Pausenregelung +- Abrechnungsintervall +- Zahlungsziel +- individuelle Vereinbarungen + +### Verknüpfungen + +- Verträge +- Aufträge +- zugewiesene Mitarbeiter +- Tätigkeitsnachweise +- Rechnungen +- Zahlungen +- Mahnungen +- Kommunikationshistorie + +--- + +## 19.3 Vertrag + +Der Vertrag beschreibt eine verbindliche Vereinbarung zwischen Omsorg und einer Einrichtung oder zwischen Omsorg und einem Mitarbeiter. + +Verträge werden nach Vertragsart getrennt geführt. + +### Vertragsarten + +- Arbeitnehmervertrag +- Rahmenvertrag mit Einrichtung +- Einsatzvertrag +- Vertragsverlängerung +- Zusatzvereinbarung +- Dienstwagenvereinbarung +- sonstige Vereinbarung + +### Vertragsdaten + +- Vertragsnummer +- Vertragsart +- Vertragspartner +- Beginn +- Ende +- Kündigungsfrist +- Verlängerungsoption +- Status +- verantwortliche Person +- Dokument + +### Fristen und Automatisierungen + +Die OMSORG Engine überwacht: + +- bevorstehendes Vertragsende +- Kündigungsfristen +- fehlende Verlängerung +- ausstehende Unterschrift +- notwendige Kontaktaufnahme + +Bei Handlungsbedarf erscheint automatisch eine Meldung im Dashboard. + +--- + +## 19.4 Auftrag + +Der Auftrag beschreibt den konkreten Personalbedarf einer Einrichtung. + +Er verbindet Einrichtung, Zeitraum, Qualifikation, Konditionen und zugewiesenen Mitarbeiter. + +### Auftragsdaten + +- Einrichtung +- Ansprechpartner +- Einsatzort +- Abteilung oder Wohnbereich +- benötigte Qualifikation +- Zeitraum +- Wochentage +- Schichtart +- Arbeitszeiten +- Anzahl benötigter Mitarbeiter +- Konditionen +- Status +- Priorität +- besondere Hinweise + +### Auftragsstatus + +```text +Anfrage +→ Prüfung +→ offen +→ teilweise besetzt +→ vollständig besetzt +→ aktiv +→ abgeschlossen +→ storniert + +Mitarbeiterzuweisung +Ein Auftrag kann einem oder mehreren Mitarbeitern zugewiesen werden. +Dabei werden geprüft: +Qualifikation +Verfügbarkeit +Arbeitszeit +Abwesenheiten +Entfernung +Überschneidungen +Vertragsbedingungen +Nach der Zuweisung erhält der Mitarbeiter eine Einsatzanweisung über OMSORG Connect. + +19.5 Zeiterfassung +Die Zeiterfassung dokumentiert die tatsächlich geleistete Arbeitszeit eines Mitarbeiters innerhalb eines Auftrags. +Sie ist die Grundlage für: +Tätigkeitsnachweis +Rechnungsstellung +Lohnvorbereitung +Auslastung +Controlling +Drei-Monats-Durchschnitt +Erfasste Daten +Mitarbeiter +Einrichtung +Auftrag +Datum +Arbeitsbeginn +Arbeitsende +Pausen +Gesamtstunden +Nachtstunden +Samstagsstunden +Sonntagsstunden +Feiertagsstunden +Bemerkungen +Tätigkeitsnachweis +Unterschrift oder Bestätigung +Prüfstatus +Status +Entwurf +→ eingereicht +→ Prüfung +→ Rückfrage +→ freigegeben +→ abgerechnet + +Nur freigegebene Zeiten dürfen in die automatische Rechnungserstellung einfließen. + + +19.6 Rechnung +Die Rechnung entsteht aus freigegebenen Arbeitszeiten und den hinterlegten Konditionen der Einrichtung. +Grundlage +freigegebene Arbeitszeit ++ Konditionen der Einrichtung ++ abrechenbare Zuschläge ++ sonstige vereinbarte Leistungen += Rechnungsentwurf + +Rechnungsdaten +Rechnungsnummer +Einrichtung +Rechnungsadresse +Abrechnungszeitraum +zugehörige Aufträge +zugehörige Mitarbeiter +Leistungspositionen +Zuschläge +Nettobetrag +Umsatzsteuer +Bruttobetrag +Zahlungsziel +Fälligkeitsdatum +Zahlungsstatus +Mahnstatus + +Rechnungsstatus +Entwurf +→ Prüfung +→ freigegeben +→ versendet +→ offen +→ bezahlt + +Alternativ: +offen +→ überfällig +→ Mahnung +→ bezahlt + +Zahlungsüberwachung +Die OMSORG Engine überwacht das Zahlungsziel von 14 Tagen. +Bei Überschreitung: +Dashboard-Warnung +Mahnentwurf +Freigabe durch berechtigte Person +Versand +Dokumentation in der Mahnhistorie + +19.7 Beziehungen zwischen den Core-Objekten +Die sechs Core-Objekte sind direkt miteinander verbunden. +Einrichtung +→ Vertrag +→ Auftrag +→ Mitarbeiterzuweisung +→ Zeiterfassung +→ Rechnung +→ Zahlung +→ Controlling +Der Mitarbeiter ist dabei mit Auftrag, Zeiterfassung, OMSORG Connect und Personalakte verbunden. +Die Einrichtung ist mit CRM, Ansprechpartnern, Konditionen, Verträgen, Aufträgen und Rechnungen verbunden. + +19.8 Verbindliche Core-Regeln +Jeder Datensatz besitzt eine eindeutige interne ID. +Beziehungen werden über IDs hergestellt und nicht durch doppelte Texteingaben. +Stammdaten werden nur an einer zentralen Stelle gepflegt. +Änderungen müssen nachvollziehbar protokolliert werden. +Löschungen sensibler oder geschäftsrelevanter Daten erfolgen nicht unkontrolliert. +Berechtigungen werden auf Daten- und Funktionsebene geprüft. +Automatisierungen greifen ausschließlich auf verlässliche, freigegebene Daten zu. +Rechnungen entstehen nur aus freigegebenen Arbeitszeiten. +Finanzdaten werden entsprechend der Benutzerrechte geschützt. +OMSORG Connect erhält nur die für den jeweiligen Mitarbeiter freigegebenen Daten. + +19.9 Bedeutung für die weitere Entwicklung +Alle zukünftigen Module müssen auf OMSORG Core aufbauen. +Es werden keine isolierten Datenlisten für Dashboard, CRM, Disposition oder Rechnungen erstellt. +Stattdessen greifen alle Bereiche auf dieselben zentralen Objekte zurück. +Damit wird sichergestellt: +keine doppelten Daten +weniger Fehler +klare Beziehungen +zuverlässige Automatisierungen +konsistente Auswertungen +spätere Erweiterbarkeit +sichere Verbindung mit OMSORG Connect +mögliche White-Label-Nutzung +```text + +--- + +# 20. OMSORG Engine + +Die OMSORG Engine ist das intelligente Herz der Plattform. + +Sie verarbeitet Ereignisse, überwacht Fristen, verbindet Module miteinander und übernimmt wiederkehrende Aufgaben automatisch. + +Die OMSORG Engine besitzt keine eigene Benutzeroberfläche. + +Sie arbeitet permanent im Hintergrund und unterstützt alle Bereiche der Software. + +--- + +## 20.1 Grundprinzip + +Die OMSORG Engine arbeitet ereignisorientiert. + +Nicht Benutzer lösen Prozesse aus. + +Sondern Ereignisse. + +Ein Ereignis kann beispielsweise sein: + +- neuer Mitarbeiter +- Krankmeldung +- neuer Auftrag +- fehlender Tätigkeitsnachweis +- Vertragsende +- neue Rechnung +- Zahlung eingegangen +- Urlaub beantragt +- Bewerbung eingegangen +- neue E-Mail +- Kaltaquise-Wiedervorlage erreicht + +Jedes Ereignis kann automatisch mehrere Aktionen auslösen. + +--- + +## 20.2 Beispiele + +### Krankmeldung + +Ereignis: + +Mitarbeiter meldet sich krank. + +OMSORG Engine: + +- Dashboard aktualisieren +- Disposition informieren +- Einsatz markieren +- mögliche Ersatzmitarbeiter anzeigen +- Einsatzanweisung vorbereiten +- Historie ergänzen + +--- + +### Fehlender Tätigkeitsnachweis + +Montagmorgen. + +OMSORG Engine prüft automatisch: + +Welche Tätigkeitsnachweise der Vorwoche fehlen? + +Danach: + +- Dashboard aktualisieren +- Mitarbeiter markieren +- Erinnerung über OMSORG Connect senden +- Rechnungsfreigabe blockieren + +--- + +### Vertragsende + +Die OMSORG Engine überwacht automatisch alle Vertragslaufzeiten. + +Beispielsweise: + +30 Tage vor Vertragsende: + +- Dashboard-Warnung +- zuständige Person informieren +- Ansprechpartner anzeigen +- Telefonnummer anzeigen +- E-Mail anzeigen +- Vertrag direkt öffnen + +--- + +### Neue Bewerbung + +Eine Bewerbung geht ein. + +OMSORG Engine: + +- Recruiting informieren +- Bewerber anlegen +- Wiedervorlage erzeugen +- Status "Neu" setzen +- Dashboard aktualisieren + +--- + +### Zahlung eingegangen + +Eine Rechnung wird bezahlt. + +OMSORG Engine: + +- Zahlungsstatus ändern +- Mahnungen stoppen +- Dashboard aktualisieren +- Controlling aktualisieren +- Liquidität aktualisieren + +--- + +## 20.3 Automatische Erinnerungen + +Die OMSORG Engine erinnert automatisch an: + +- Vertragsverlängerungen +- Wiedervorlagen +- Tätigkeitsnachweise +- Rechnungen +- Mahnungen +- Mitarbeiterstarts +- Führerschein +- Fortbildungen +- Dokumente +- Probezeit +- Urlaubsablauf +- Fahrzeugservice + +--- + +## 20.4 OMSORG Connect + +OMSORG Connect arbeitet vollständig mit der OMSORG Engine zusammen. + +Beispiele: + +Mitarbeiter: + +"Krank" + +↓ + +OMSORG Engine + +↓ + +Dashboard + +↓ + +Disposition + +↓ + +Ersatzplanung + +--- + +Mitarbeiter: + +"Tätigkeitsnachweis hochgeladen" + +↓ + +OMSORG Engine + +↓ + +Zeiterfassung + +↓ + +Rechnung + +↓ + +Dashboard + +--- + +## 20.5 Dashboard + +Das Dashboard zeigt keine statischen Informationen. + +Alle Inhalte stammen aus der OMSORG Engine. + +Beispiele: + +- neue Aufgaben +- Erinnerungen +- Krankmeldungen +- offene Rechnungen +- Kaltaquise +- neue Bewerbungen +- Verträge +- Mitarbeiterstarts + +Das Dashboard zeigt immer den aktuellen Arbeitsbedarf. + +--- + +## 20.6 Grundsatz + +Die OMSORG Engine unterstützt Menschen. + +Sie ersetzt keine Entscheidungen. + +Sie nimmt Routinearbeiten ab. + +Sie erinnert. + +Sie verbindet Informationen. + +Sie sorgt dafür, dass nichts vergessen wird. + +--- + +## 20.7 Langfristige Entwicklung + +Die OMSORG Engine wird zukünftig erweitert um: + +- Outlook-Integration +- intelligente Vorschläge +- automatische Einsatzempfehlungen +- KI-Unterstützung +- Prognosen +- Workflow-Automatisierungen +- White-Label-Unterstützung + +--- + +## 20.8 Leitsatz + +OMSORG denkt mit. + +Nicht der Benutzer soll nach Informationen suchen. + +Die richtige Information soll zur richtigen Zeit automatisch erscheinen. +```text \ No newline at end of file diff --git a/omsorgapp/electron/backend/authClient.cjs b/omsorgapp/electron/backend/authClient.cjs new file mode 100644 index 0000000..cd46dd8 --- /dev/null +++ b/omsorgapp/electron/backend/authClient.cjs @@ -0,0 +1,73 @@ +const { request } = require('./httpClient.cjs'); + +// Kapselt /api/auth/* von omsorgCore. Kennt die Feldnamen der LoginResponse +// (camelCase, da ASP.NET Core standardmäßig camelCase-JSON ausgibt). +function toTokenPair(data) { + return { + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresAt: data.expiresAt, + mustChangePassword: !!data.mustChangePassword + }; +} + +async function login(username, password) { + const result = await request('POST', '/api/auth/login', { body: { username, password } }); + if (!result.ok) { + return { ok: false, status: result.status, error: result.error || result.data?.error }; + } + return { ok: true, ...toTokenPair(result.data) }; +} + +async function refresh(refreshToken) { + const result = await request('POST', '/api/auth/refresh', { body: { refreshToken } }); + if (!result.ok) return { ok: false, status: result.status }; + return { ok: true, ...toTokenPair(result.data) }; +} + +async function logout(refreshToken) { + const result = await request('POST', '/api/auth/logout', { body: { refreshToken } }); + return { ok: result.ok, status: result.status }; +} + +async function me(accessToken) { + const result = await request('GET', '/api/auth/me', { accessToken }); + if (!result.ok) return { ok: false, status: result.status }; + return { ok: true, username: result.data.username, role: result.data.role, permissions: result.data.permissions }; +} + +async function requestPasswordReset(username) { + const result = await request('POST', '/api/auth/forgot-password/request', { body: { username } }); + if (!result.ok) return { ok: false, status: result.status }; + return { ok: true, status: result.data.status }; +} + +async function verifyPasswordResetCode(username, pin) { + const result = await request('POST', '/api/auth/forgot-password/verify', { body: { username, pin } }); + if (!result.ok) return { ok: false, status: result.status, error: result.data?.error }; + return { ok: true, resetToken: result.data.resetToken }; +} + +async function resetPassword(resetToken, newPassword) { + const result = await request('POST', '/api/auth/forgot-password/reset', { body: { resetToken, newPassword } }); + return { ok: result.ok, status: result.status }; +} + +async function changePassword(accessToken, currentPassword, newPassword) { + const result = await request('POST', '/api/auth/change-password', { + body: { currentPassword, newPassword }, + accessToken + }); + return { ok: result.ok, status: result.status, data: result.data }; +} + +module.exports = { + login, + refresh, + logout, + me, + requestPasswordReset, + verifyPasswordResetCode, + resetPassword, + changePassword +}; diff --git a/omsorgapp/electron/backend/config.cjs b/omsorgapp/electron/backend/config.cjs new file mode 100644 index 0000000..5bdf358 --- /dev/null +++ b/omsorgapp/electron/backend/config.cjs @@ -0,0 +1,6 @@ +// Einzige Stelle im Projekt, die die Adresse von omsorgCore kennt. +// Default passt zum lokalen `dotnet run --project src/OmsorgCore.Api` (http-Profil, Port 5245), +// überschreibbar per Umgebungsvariable für andere Umgebungen (Produktion, anderer Rechner, ...). +module.exports = { + API_BASE: process.env.OMSORG_CORE_URL || 'http://localhost:5245' +}; diff --git a/omsorgapp/electron/backend/employeesClient.cjs b/omsorgapp/electron/backend/employeesClient.cjs new file mode 100644 index 0000000..50eb18c --- /dev/null +++ b/omsorgapp/electron/backend/employeesClient.cjs @@ -0,0 +1,28 @@ +const { request } = require('./httpClient.cjs'); + +// Kapselt /api/employees von omsorgCore (EmployeesController). Feldnamen sind +// camelCase, da ASP.NET Core standardmäßig camelCase-JSON ausgibt. +async function listEmployees(accessToken, { search, status, employmentType, page = 1, pageSize = 20 } = {}) { + const params = new URLSearchParams(); + if (search) params.set('search', search); + if (status) params.set('status', status); + if (employmentType) params.set('employmentType', employmentType); + params.set('page', page); + params.set('pageSize', pageSize); + + return request('GET', `/api/employees?${params.toString()}`, { accessToken }); +} + +async function createEmployee(accessToken, payload) { + return request('POST', '/api/employees', { body: payload, accessToken }); +} + +async function getEmployee(accessToken, id) { + return request('GET', `/api/employees/${id}`, { accessToken }); +} + +async function updateEmployee(accessToken, id, payload) { + return request('PUT', `/api/employees/${id}`, { body: payload, accessToken }); +} + +module.exports = { listEmployees, createEmployee, getEmployee, updateEmployee }; diff --git a/omsorgapp/electron/backend/httpClient.cjs b/omsorgapp/electron/backend/httpClient.cjs new file mode 100644 index 0000000..becb60f --- /dev/null +++ b/omsorgapp/electron/backend/httpClient.cjs @@ -0,0 +1,34 @@ +const { API_BASE } = require('./config.cjs'); + +// Einzige Stelle, die fetch/API_BASE/Authorization-Header kennt. Kennt keine konkreten +// Endpunkte - die kommen aus den Client.cjs-Dateien (z.B. authClient.cjs). +async function request(method, path, { body, accessToken } = {}) { + const headers = { 'Content-Type': 'application/json' }; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + + let response; + try { + response = await fetch(`${API_BASE}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined + }); + } catch (err) { + return { ok: false, status: 0, data: null, error: err.message }; + } + + if (response.status === 204) { + return { ok: true, status: 204, data: null }; + } + + let data = null; + try { + data = await response.json(); + } catch { + data = null; + } + + return { ok: response.ok, status: response.status, data }; +} + +module.exports = { request }; diff --git a/omsorgapp/electron/backend/usersClient.cjs b/omsorgapp/electron/backend/usersClient.cjs new file mode 100644 index 0000000..8f0e261 --- /dev/null +++ b/omsorgapp/electron/backend/usersClient.cjs @@ -0,0 +1,12 @@ +const { request } = require('./httpClient.cjs'); + +// Kapselt /api/users von omsorgCore (UsersController). +async function listUsers(accessToken) { + return request('GET', '/api/users', { accessToken }); +} + +async function createUser(accessToken, payload) { + return request('POST', '/api/users', { body: payload, accessToken }); +} + +module.exports = { listUsers, createUser }; diff --git a/omsorgapp/electron/main.cjs b/omsorgapp/electron/main.cjs new file mode 100644 index 0000000..4f2403d --- /dev/null +++ b/omsorgapp/electron/main.cjs @@ -0,0 +1,296 @@ +const { app, BrowserWindow, ipcMain, shell, safeStorage } = require('electron'); +const path = require('path'); +const fs = require('fs'); +const authClient = require('./backend/authClient.cjs'); +const httpClient = require('./backend/httpClient.cjs'); +const employeesClient = require('./backend/employeesClient.cjs'); +const usersClient = require('./backend/usersClient.cjs'); + +app.commandLine.appendSwitch('lang', 'de'); + +let mainWindow; +const isDev = !app.isPackaged; + +let session = { accessToken: null, expiresAt: null, user: null, mustChangePassword: false }; +let refreshTimer = null; +const REFRESH_BUFFER_MS = 2 * 60 * 1000; + +function sessionFilePath() { return path.join(app.getPath('userData'), 'session.enc'); } + +function saveRefreshToken(rawToken) { + if (!safeStorage.isEncryptionAvailable()) return; + fs.writeFileSync(sessionFilePath(), safeStorage.encryptString(rawToken)); +} +function loadRefreshToken() { + const file = sessionFilePath(); + if (!fs.existsSync(file) || !safeStorage.isEncryptionAvailable()) return null; + try { + return safeStorage.decryptString(fs.readFileSync(file)); + } catch { + return null; + } +} +function clearRefreshToken() { + const file = sessionFilePath(); + if (fs.existsSync(file)) fs.unlinkSync(file); +} + +// Nur für die Anzeige "angemeldet als ..." im Renderer - die Signaturprüfung passiert +// serverseitig bei jedem authentifizierten Aufruf, hier wird nichts sicherheitsrelevantes entschieden. +function decodeJwtClaims(token) { + try { + const payload = token.split('.')[1]; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')); + } catch { + return {}; + } +} +function userFromAccessToken(accessToken) { + const claims = decodeJwtClaims(accessToken); + return { + username: claims['name'] || claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || null, + role: claims['role'] || claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role'] || null + }; +} + +function broadcastSession() { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('auth:sessionChanged', { + isAuthenticated: !!session.accessToken, + user: session.user, + mustChangePassword: session.mustChangePassword + }); + } +} + +function scheduleRefresh() { + if (refreshTimer) clearTimeout(refreshTimer); + if (!session.expiresAt) return; + const delay = Math.max(new Date(session.expiresAt).getTime() - Date.now() - REFRESH_BUFFER_MS, 5000); + refreshTimer = setTimeout(refreshSilently, delay); +} + +function applySession(tokenPair) { + session = { + accessToken: tokenPair.accessToken, + expiresAt: tokenPair.expiresAt, + user: userFromAccessToken(tokenPair.accessToken), + mustChangePassword: !!tokenPair.mustChangePassword + }; + saveRefreshToken(tokenPair.refreshToken); + scheduleRefresh(); + broadcastSession(); + refreshPermissions(); +} + +// Rechte kommen nicht aus dem JWT (das trägt nur den Rollennamen), sondern aus /api/auth/me - +// läuft nach dem schnellen JWT-basierten Zwischenstand nach und broadcastet erneut, sobald da. +async function refreshPermissions() { + const accessToken = session.accessToken; + if (!accessToken) return; + const profile = await authClient.me(accessToken); + if (!profile.ok || session.accessToken !== accessToken) return; + session.user = { username: profile.username, role: profile.role, permissions: profile.permissions }; + broadcastSession(); +} + +function clearSession() { + session = { accessToken: null, expiresAt: null, user: null, mustChangePassword: false }; + clearRefreshToken(); + if (refreshTimer) clearTimeout(refreshTimer); + refreshTimer = null; + broadcastSession(); +} + +// Proaktiver Timer kurz vor Ablauf UND Fallback bei 401 (z.B. nach Standby) - siehe Plan. +async function refreshSilently() { + const rawRefreshToken = loadRefreshToken(); + if (!rawRefreshToken) { + clearSession(); + return false; + } + const result = await authClient.refresh(rawRefreshToken); + if (!result.ok) { + clearSession(); + return false; + } + applySession(result); + return true; +} + +async function bootstrapSession() { + const rawRefreshToken = loadRefreshToken(); + if (!rawRefreshToken) return; + const result = await authClient.refresh(rawRefreshToken); + if (result.ok) { + applySession(result); + } else { + clearRefreshToken(); + } +} + +// Ruft callFn(accessToken) auf und wiederholt einmal nach Silent-Refresh bei 401 - +// gemeinsame Grundlage für den generischen api:*-Proxy und die Client.cjs-Aufrufe. +async function withAuthRetry(callFn) { + if (!session.accessToken) return { ok: false, status: 401, data: null }; + let result = await callFn(session.accessToken); + if (result.status === 401 && (await refreshSilently())) { + result = await callFn(session.accessToken); + } + return result; +} + +function authorizedRequest(method, resourcePath, body) { + return withAuthRetry((accessToken) => httpClient.request(method, resourcePath, { body, accessToken })); +} + +function ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } +function appRoot() { + const base = path.join(app.getPath('documents'), 'Omsorg Business Controls Pro'); + ensureDir(base); + ensureDir(path.join(base, 'database')); + ensureDir(path.join(base, 'documents')); + ensureDir(path.join(base, 'documents', 'employees')); + ensureDir(path.join(base, 'documents', 'customers')); + ensureDir(path.join(base, 'backups')); + return base; +} +function dbPath() { return path.join(appRoot(), 'database', 'omsorg-local-db.json'); } +function defaultData() { + return { + version: '0.1.1', + createdAt: new Date().toISOString(), + users: [ + { id:'u1', name:'Sabina Dautovic', role:'Geschäftsführung' }, + { id:'u2', name:'Malik Neumann', role:'Geschäftsführung' }, + { id:'u3', name:'Sabrina Berggötz', role:'Disposition' } + ], + employees: [ + { id:'e1', name:'Dirk Artmann', qualification:'3-jährige Pflegefachkraft', status:'Aktiv' }, + { id:'e2', name:'Carina Vetter', qualification:'3-jährige Pflegefachkraft', status:'Aktiv' }, + { id:'e3', name:'Dilara Anders', qualification:'1-jährige Pflegekraft', status:'Aktiv' }, + { id:'e4', name:'Pascal Galliot', qualification:'1-jährige Pflegekraft', status:'Aktiv' } + ], + customers: [], + assignments: [], + notes: [] + }; +} +function readDb() { + const file = dbPath(); + if (!fs.existsSync(file)) fs.writeFileSync(file, JSON.stringify(defaultData(), null, 2)); + return JSON.parse(fs.readFileSync(file, 'utf-8')); +} +function writeDb(data) { fs.writeFileSync(dbPath(), JSON.stringify(data, null, 2)); return data; } +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1440, + height: 920, + minWidth: 1100, + minHeight: 760, + title: 'Omsorg Business Controls Pro', + backgroundColor: '#08111f', + webPreferences: { + preload: path.join(__dirname, 'preload.cjs'), + contextIsolation: true, + nodeIntegration: false + } + }); + if (isDev) mainWindow.loadURL('http://127.0.0.1:5173'); + else mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); +} +app.whenReady().then(async () => { + appRoot(); + createWindow(); + await bootstrapSession(); + broadcastSession(); +}); +app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); +app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); + +ipcMain.handle('db:get', () => readDb()); +ipcMain.handle('db:set', (_event, data) => writeDb(data)); +ipcMain.handle('app:paths', () => ({ root: appRoot(), db: dbPath() })); +ipcMain.handle('app:openRoot', () => shell.openPath(appRoot())); +ipcMain.handle('backup:create', () => { + const root = appRoot(); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupFile = path.join(root, 'backups', `backup-${stamp}.json`); + fs.copyFileSync(dbPath(), backupFile); + return backupFile; +}); + +ipcMain.handle('auth:login', async (_event, { username, password }) => { + const result = await authClient.login(username, password); + if (!result.ok) { + const error = result.status === 0 + ? 'network_error' + : result.status === 429 + ? 'too_many_attempts' + : 'invalid_credentials'; + return { success: false, error }; + } + applySession(result); + return { success: true, user: session.user, mustChangePassword: session.mustChangePassword }; +}); + +ipcMain.handle('auth:logout', async () => { + const rawRefreshToken = loadRefreshToken(); + if (rawRefreshToken) { + try { await authClient.logout(rawRefreshToken); } catch { /* best effort */ } + } + clearSession(); + return { success: true }; +}); + +ipcMain.handle('auth:getSession', () => ({ + isAuthenticated: !!session.accessToken, + user: session.user, + mustChangePassword: session.mustChangePassword +})); + +ipcMain.handle('auth:requestPasswordReset', async (_event, { username }) => { + const result = await authClient.requestPasswordReset(username); + if (!result.ok) return { success: false, error: 'network_error' }; + return { success: true, status: result.status }; +}); + +ipcMain.handle('auth:verifyPasswordResetCode', async (_event, { username, pin }) => { + const result = await authClient.verifyPasswordResetCode(username, pin); + if (!result.ok) return { success: false, error: result.error || 'invalid_or_expired' }; + return { success: true, resetToken: result.resetToken }; +}); + +ipcMain.handle('auth:resetPassword', async (_event, { resetToken, newPassword }) => { + const result = await authClient.resetPassword(resetToken, newPassword); + return { success: result.ok }; +}); + +// Erfolgreicher Wechsel widerruft serverseitig alle Sessions (siehe UserService.ChangeOwnPasswordAsync) - +// der aktuelle Access-Token ist danach tot, also Session lokal beenden statt weiterzumachen. +ipcMain.handle('auth:changePassword', async (_event, { currentPassword, newPassword }) => { + if (!session.accessToken) return { success: false, error: 'not_authenticated' }; + const result = await authClient.changePassword(session.accessToken, currentPassword, newPassword); + if (!result.ok) { + return { success: false, error: result.status === 401 ? 'invalid_current_password' : 'unknown' }; + } + clearSession(); + return { success: true }; +}); + +// Generischer Proxy für Ressourcen ohne eigene Client.cjs-Datei (siehe Plan). +ipcMain.handle('api:get', (_event, resourcePath) => authorizedRequest('GET', resourcePath)); +ipcMain.handle('api:post', (_event, resourcePath, body) => authorizedRequest('POST', resourcePath, body)); + +ipcMain.handle('employees:list', (_event, params) => withAuthRetry((accessToken) => employeesClient.listEmployees(accessToken, params))); +ipcMain.handle('employees:create', (_event, payload) => + withAuthRetry((accessToken) => employeesClient.createEmployee(accessToken, payload)) +); +ipcMain.handle('employees:update', (_event, id, payload) => + withAuthRetry((accessToken) => employeesClient.updateEmployee(accessToken, id, payload)) +); + +ipcMain.handle('users:list', () => withAuthRetry((accessToken) => usersClient.listUsers(accessToken))); +ipcMain.handle('users:create', (_event, payload) => + withAuthRetry((accessToken) => usersClient.createUser(accessToken, payload)) +); diff --git a/omsorgapp/electron/preload.cjs b/omsorgapp/electron/preload.cjs new file mode 100644 index 0000000..1535868 --- /dev/null +++ b/omsorgapp/electron/preload.cjs @@ -0,0 +1,36 @@ +const { contextBridge, ipcRenderer } = require('electron'); +contextBridge.exposeInMainWorld('omsorg', { + getDb: () => ipcRenderer.invoke('db:get'), + setDb: (data) => ipcRenderer.invoke('db:set', data), + paths: () => ipcRenderer.invoke('app:paths'), + openRoot: () => ipcRenderer.invoke('app:openRoot'), + createBackup: () => ipcRenderer.invoke('backup:create'), + auth: { + login: (username, password) => ipcRenderer.invoke('auth:login', { username, password }), + logout: () => ipcRenderer.invoke('auth:logout'), + getSession: () => ipcRenderer.invoke('auth:getSession'), + onSessionChanged: (callback) => { + const listener = (_event, session) => callback(session); + ipcRenderer.on('auth:sessionChanged', listener); + return () => ipcRenderer.removeListener('auth:sessionChanged', listener); + }, + requestPasswordReset: (username) => ipcRenderer.invoke('auth:requestPasswordReset', { username }), + verifyPasswordResetCode: (username, pin) => ipcRenderer.invoke('auth:verifyPasswordResetCode', { username, pin }), + resetPassword: (resetToken, newPassword) => ipcRenderer.invoke('auth:resetPassword', { resetToken, newPassword }), + changePassword: (currentPassword, newPassword) => + ipcRenderer.invoke('auth:changePassword', { currentPassword, newPassword }) + }, + api: { + get: (path) => ipcRenderer.invoke('api:get', path), + post: (path, body) => ipcRenderer.invoke('api:post', path, body) + }, + employees: { + list: (params) => ipcRenderer.invoke('employees:list', params), + create: (payload) => ipcRenderer.invoke('employees:create', payload), + update: (id, payload) => ipcRenderer.invoke('employees:update', id, payload) + }, + users: { + list: () => ipcRenderer.invoke('users:list'), + create: (payload) => ipcRenderer.invoke('users:create', payload) + } +}); diff --git a/omsorgapp/index.html b/omsorgapp/index.html new file mode 100644 index 0000000..63e7b66 --- /dev/null +++ b/omsorgapp/index.html @@ -0,0 +1 @@ +Omsorg Business Controls Pro
diff --git a/omsorgapp/package-lock.json b/omsorgapp/package-lock.json new file mode 100644 index 0000000..1a0508c --- /dev/null +++ b/omsorgapp/package-lock.json @@ -0,0 +1,2132 @@ +{ + "name": "omsorg-business-controls-pro", + "version": "0.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "omsorg-business-controls-pro", + "version": "0.1.1", + "dependencies": { + "@vitejs/plugin-react": "latest", + "electron": "latest", + "lucide-react": "latest", + "react": "latest", + "react-dom": "latest", + "typescript": "latest", + "vite": "latest" + }, + "devDependencies": { + "concurrently": "latest", + "wait-on": "latest" + } + }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@electron/get": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", + "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concurrently": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron": { + "version": "43.1.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.1.0.tgz", + "integrity": "sha512-DPfxpQLd4NL3BJ8DBxYAfmLUKKesF5Rx9dQx5FyczAP8bhOPScjHE48GArVeXu68LlAainuwkmQTQvdZwpIIAQ==", + "license": "MIT", + "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" + }, + "engines": { + "node": ">= 22.12.0" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/joi": { + "version": "18.2.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", + "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shell-quote": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", + "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.16", + "rolldown": "~1.1.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wait-on": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + } + } +} diff --git a/omsorgapp/package.json b/omsorgapp/package.json new file mode 100644 index 0000000..dd18c5b --- /dev/null +++ b/omsorgapp/package.json @@ -0,0 +1,25 @@ +{ + "name": "omsorg-business-controls-pro", + "version": "0.1.1", + "private": true, + "description": "Omsorg Business Controls Pro - Release 0.1.1 Foundation", + "main": "electron/main.cjs", + "scripts": { + "dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && electron .\"", + "web": "vite --host 127.0.0.1", + "start": "electron ." + }, + "dependencies": { + "@vitejs/plugin-react": "latest", + "vite": "latest", + "typescript": "latest", + "react": "latest", + "react-dom": "latest", + "electron": "latest", + "lucide-react": "latest" + }, + "devDependencies": { + "concurrently": "latest", + "wait-on": "latest" + } +} diff --git a/omsorgapp/src/app/AuthContext.jsx b/omsorgapp/src/app/AuthContext.jsx new file mode 100644 index 0000000..75478e6 --- /dev/null +++ b/omsorgapp/src/app/AuthContext.jsx @@ -0,0 +1,82 @@ +import { createContext, useContext, useEffect, useState, useCallback } from "react"; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [mustChangePassword, setMustChangePassword] = useState(false); + + useEffect(() => { + let cancelled = false; + + window.omsorg.auth.getSession().then((session) => { + if (cancelled) return; + setIsAuthenticated(session.isAuthenticated); + setUser(session.user); + setMustChangePassword(!!session.mustChangePassword); + setIsLoading(false); + }); + + const unsubscribe = window.omsorg.auth.onSessionChanged((session) => { + setIsAuthenticated(session.isAuthenticated); + setUser(session.user); + setMustChangePassword(!!session.mustChangePassword); + setIsLoading(false); + }); + + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + const login = useCallback(async (username, password) => { + const result = await window.omsorg.auth.login(username, password); + if (result.success) { + setIsAuthenticated(true); + setUser(result.user); + setMustChangePassword(!!result.mustChangePassword); + } + return result; + }, []); + + const logout = useCallback(async () => { + await window.omsorg.auth.logout(); + setIsAuthenticated(false); + setUser(null); + setMustChangePassword(false); + }, []); + + const changePassword = useCallback(async (currentPassword, newPassword) => { + const result = await window.omsorg.auth.changePassword(currentPassword, newPassword); + if (result.success) { + // Serverseitig wurden alle Sessions widerrufen (siehe UserService.ChangeOwnPasswordAsync) - + // der aktuelle Access-Token ist schon tot, also lokal neu einloggen lassen statt weiterzumachen. + setIsAuthenticated(false); + setUser(null); + setMustChangePassword(false); + } + return result; + }, []); + + const hasPermission = useCallback( + (module, action) => !!user?.permissions?.some((p) => p.module === module && p.action === action), + [user] + ); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth muss innerhalb von AuthProvider verwendet werden"); + return ctx; +} diff --git a/omsorgapp/src/app/app.jsx b/omsorgapp/src/app/app.jsx new file mode 100644 index 0000000..0338663 --- /dev/null +++ b/omsorgapp/src/app/app.jsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react"; +import AppLayout from "../layouts/AppLayout"; +import HomePage from "../modules/home/HomePage"; +import EmployeesPage from "../modules/employees/EmployeesPage"; +import LoginPage from "../modules/auth/LoginPage"; +import ChangePasswordScreen from "../modules/auth/ChangePasswordScreen"; +import ForgotPasswordUsernamePage from "../modules/auth/ForgotPasswordUsernamePage"; +import ForgotPasswordPinPage from "../modules/auth/ForgotPasswordPinPage"; +import ForgotPasswordNewPasswordPage from "../modules/auth/ForgotPasswordNewPasswordPage"; +import DebugSessionsPage from "../modules/debug/DebugSessionsPage"; +import { useAuth } from "./AuthContext"; +import { isNavItemVisible } from "./navPermissions"; +function PlaceholderPage({ title }) { + return ( +
+

{title}

+

Dieses Modul bauen wir als Nächstes vollständig aus.

+
+ ); +} + +export default function App() { + const [activePage, setActivePage] = useState("Home"); + const [authView, setAuthView] = useState({ screen: "login" }); + const { isAuthenticated, isLoading, hasPermission, mustChangePassword } = useAuth(); + + useEffect(() => { + if (isAuthenticated && !isNavItemVisible(activePage, hasPermission)) { + setActivePage("Home"); + } + }, [isAuthenticated, activePage, hasPermission]); + + if (isLoading) { + return
Lädt...
; + } + + if (!isAuthenticated) { + switch (authView.screen) { + case "forgotUsername": + return ( + setAuthView({ screen: "forgotPin", username })} + onBackToLogin={() => setAuthView({ screen: "login" })} + /> + ); + case "forgotPin": + return ( + setAuthView({ screen: "forgotNewPassword", resetToken })} + onRestart={() => setAuthView({ screen: "forgotUsername" })} + onBackToLogin={() => setAuthView({ screen: "login" })} + /> + ); + case "forgotNewPassword": + return ( + setAuthView({ screen: "login" })} + onBackToLogin={() => setAuthView({ screen: "login" })} + /> + ); + default: + return setAuthView({ screen: "forgotUsername" })} />; + } + } + + if (mustChangePassword) { + return ; + } + + const renderPage = () => { + switch (activePage) { + case "Home": + return ; + case "Mitarbeiter": + return ; + case "Kunden": + return ; + case "Disposition": + return ; + case "Kalkulation": + return ; + case "Fahrzeuge": + return ; + case "Rechnungen": + return ; + case "Controlling": + return ; + case "Einstellungen": + return ; + case "Debug": + return ; + default: + return ; + } + }; + + return ( + + {renderPage()} + + ); +} \ No newline at end of file diff --git a/omsorgapp/src/app/navPermissions.js b/omsorgapp/src/app/navPermissions.js new file mode 100644 index 0000000..d6e8cc0 --- /dev/null +++ b/omsorgapp/src/app/navPermissions.js @@ -0,0 +1,14 @@ +export const NAV_MODULES = { + Mitarbeiter: "Employees", + Kunden: "Facilities", + Disposition: "Orders", + Rechnungen: "Invoices", + Controlling: "Controlling", + Einstellungen: "UserManagement", + Debug: "UserManagement" +}; + +export function isNavItemVisible(label, hasPermission) { + const module = NAV_MODULES[label]; + return !module || hasPermission(module, "View"); +} diff --git a/omsorgapp/src/assets/omsorg_logo.png b/omsorgapp/src/assets/omsorg_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2edf5c079f2ef1b95a8c8460323fe469884f4310 GIT binary patch literal 33617 zcmce71yh{SvhCo(-QC@7us~qYAOQlw-Q6_=clY4IJ-7uAFhFp3cZXmh*!yzsJ+Iy` zcvaLCObuU8@7=q5_3E`FKPbzhqmZD0Kp=E^Ifxnv1Sbppe)R?scm?&2dVoN|MDmb# zAHA~Aay-3EwY^`vWjo(%I+OcCXFNSDN9-;-37hLi>e+Izd;;j;zl0=&goI4O(!s-q zWKK5s)Z1J5Uf5fdPAu-Q=yrPMY)~eNawM^@`D%Q>`I2i%p{1J?e=c^PecgO90zRO6 z6ZAhnhi-Ud6rgul@c;RNuu4F&|N9605SZq_9|Qfr?}K>a{`)=^D8U)>zyAym6A(lD zzmKJQ12QAS{-1}!zXMV2MEutxEQnb4zwcv#{y&=lm1T;7+P0KS+BkEh>C&aZSyFuI zNCy%$c}O587^_qird}gWW+PQ*V-4oI-XhB}a_^lb{J84{+A9mpV5cI0-iU2SJRyT$ZYsf|B3l%+UX&#`;ze-S#`6C>g0aM#v#up2XC$k^{v zBuKQw*s`NY)Wg`6=x)X~Wob$T7BSB-M4aVJ5Rl5n;WfAd*~ zmAOTd_1jE2W4c_Z7ENfzN5m^p!Y6N(01-Zx8=E;I0js+4Z}!%*!>svWXfkt$Yih%i z6wHp^pHN3Z23+PY)})?uE7)+8@_N$5G;XvbexhmH&DpZ8$ueNCznP)wAV~3Gn*H_; zsTcB6CvH_ICX+AX&QS<9J<8@|!3RT&`sysi#t(T~PK)rP1atJcdcg;8EM4E2yAs8g zUdn*umfSS!yIY2f`fkMpzJ4^j7mT)h8!y2SzT}2j6S}iVsg#oQcx#mNE*h5&v1;1d zNP{(BmF*YVzFxDzbm?c~e_yc63a8xrL;y~SzfMm!J4J@-P4znUXuQ|CdB0`@oXFFCW9?#iR=!7>&1>I`>{wp zpR5IQv2MvM;mLKNM@W`0ONuJ1&yFQnYziI&s%Cx=3frR-Oi;QU1D-IpZug#2=pc!@ zYWeAW8pnV=nkMA#5=T)@BIU#W*Ubhz%d$AfVwHkoMU=235q6L`dXPBo+F$SehesQT zoZTdAEC~RYedj-$tKEzz03|{Gg7J_5*dc$uYkqI&XCyFWhymqF z{WbbLny+fopc>DQh>qAuf)=fn%u>focCZu4hcS>0oC6wtz3*-)VLQM&nsJ+CRd=oL zEl6^|^l_`Kkjk!zy?ZgC3LEym_j;?`gtAhFNM<=7ow?x{V)FSDUEspcD+EL*WW>1G zf#-v4jAj3GccedXx!;N~vN18+oC*{RIPWLmA56QPq-j;h0+QKevJbdS^~vWmm3QZi zV!H97On|2p@B40LQaTD4RSpEqv@0g*U4N3|ev1R-h1{Q8UM>z@B6BR+R{Wcz9Jqho z$HvV59*$#U1jH0t{Ptmj=3PqH3T86-QhkB}uQ~i&5>VI2pyWgyd5WU@^KP5sF4^4gv`)7Tz;(czTqGMu{BVyLAa zFT{OmkzWjDEeIiI15}JVW&Mt&pP#rLy|j_`EDA9`-7g;VFs_gT2PY2&a#N;P;YvufXEo3HZQ)?Yp{_-MbJRmUHc@{1AGVH;_ z@y(0k!>K(#9EY2hlT=} z9K#syE3*A#I8f!3Y;T%g71s8K@Wlp~UC(*}9Y2NI1;)-y9Qv6J{qBr$3)7BglhnCuI#owMgy=i?YgwWpUQ zDg#W7xaAr_D`)c-eBIHpYVk@zkv`{_yh7e2GtQwYFl<2Z6y57Z-4gx@p&+SyYtu&D zI)g09ZjTqu0OeQot)>rE+p8q=woXd%)z%Y>BfU)iQ2$wK%MV<7r;VExx!=QL4+N9n zzO;%9tLCSp5dH0 zLxWnJzaYik)rb3Y3qm51zkjjpkac*I_4<})#1Ng3jPA^cOzaHx`q5JAO-dy*lS|ZG z04AEd;GQo<9R0&Z=`#`Y`k+e{;E+Bprc>-DA+EuTokT}OuwHB-&g*BRBu-BP7!-GT z!gjXSx)$y@vo`KTxhwH;_&V1N7no9=P}0XAPHNGk_>RIOd3WJgMdCq_IH?-5)*<6w zesr#+w+)YPRCd9{va#E-A7Wm=2!k9;>bsZAHMj3z7zMJj9Nk3EFt0b15E2abyX<(5 zF7FIP5SKq5eK#Y^G2`ECGBS63&z`+vZiDPSq`cqYNWKMf@<~UIf1oK4zd*~vzOYW> zl|nJVka^Ibfq}0VB1%QkJLK9qM)Wx@+BISu_VkLIM|a>qxw?DXP6g-qXqT`dhMWE9 zC`2mjTj>1KUfvUwSvRmar?C2V(+at8$J+nv>lc<9oBPy8C%u`IXwq=jEZ?-IfWBr**=j!xlk8|Wq6e(#+K3xcsMbJ}X z!HhDn7T?=Z3Bf1H15O7idE%$2Z~z3nh2|(;zvkR*^|(lHX77lG6Str@&gXjrNJ*UH zo{d!6#q}#{&$7On00y#9YgB5mr?U{#IshJKncBiJsV8D69b-`ZC ziVw*eF#isQs}37N>y%v{`fu?u#6<0Vt+Pp{t3ZwKKW6I}g3ofP{EY-c%yLa7Dxo5O z$+aAl98Qr6L(y0T% z^=aH3&40Ax!lq2ugSPVadLk{v9F9GE%2kKYeLtEkmYL@1Zp0J9seWk@323(r^5Q1i zS-4qD)3?2s#eH({1QkX$(j{(NsExtM_XVsVWrLMy=vm4iDsP`pDO&Qx8;3ry_x4`4 ziU%$Hg8D$0>87fP(pFnoTE3AGrwajYb@Hz!kKoO$wR z5G7J!6MvUuL;tqEr1Yphl-2npAQ+vr@re+3y(~}YOI}NKx&g4WD+fT~G6-1;kzO3}0Hkx2#=R`ob@r`7 zcVy}F7ueFjGP2iTzC5UGuY*o5=kA>Np4ojU-4f5b)a1L7ToV1lwcjg0Pn5orUxOo% zTE8S^MeO57a+j;2{1QytpdrMbiwVX0weQ6kNu@!LFd_8)sBT!mo&>Zma%gg}JodDp z{#KMS#@LW=cthk6PY+I718D}N2cph~QV%`Q7rF$jd z#b|}*?^m*%j1zvfI6^7znDh5nn9auLjy1}Ced`hDtuz<9k;z@B`=7L#{7xUdK}W zrI`=kR+AVmgQ>C}{p-j2Pi^pD&l8F1TvEGNZ1cfgt)AK4j+69{24Ip5S&rg;NM&Nq z8P~Pt)0|klZ-M;hvrW9$69HB1>JY)4J#mUGc1U|VcMK62aNlN$$(g;1_jvJjg)2b( z2i(&;6|4>6Q)n>b_@9q{sgaVcr27^FiXdBIO}AIrIggN zk{xUaiw4UHgrE4@k((2xE?V%`JCe0$qMz`OQ7GvD87qo^{XE@LU_4u>I{b@^h1+hn zP6hM+XPMqh>*mPrWo+Nf;yLp4-(`yq+n!;zisSfb+DN{ahfTAU@nHQe@au8laDnkh zVuS$9#z-o@VeU=mRy{Bl#el-Ihby~v@MX!2=i#Zq3{n$-=JTaR$ z@qd55FObRk*#PXR8U!`LA}Mh!bC5rb}|Ko?&dCAAv6BE4aVS^c1NDxKgEz z^=6Hm2s79Cs4dCF$(YE$IEI&0^2E&lTIFJ?8wXOA_5zX=KC`^a-@-q45$yx^*UPQ; zJ;Oyqh$_x;;$!g3$IFd008sE|OTQQ@12P`g>p%!08QKMzlShZ~$8D*~_N6`L_YL9H znZuAMf8z-+jj$<}oDTFtoM0dJ$J1p4EzJljm>$$#qJJw;U1#4fP?=a=&i%KYF|+?Q z#(UX<;>m5b@1mMYjfavR1*6V`KW6{>g_^M4ug8FkfvC?;IC|lyv*=#@_K^gh2raT= zDNj953@C#{wkF)MOVdsxU0!J$}RB;xPmtxb5FUz=@&glAO3>z z`FfJipPESbAvAj@PGh{8U{!lm$OHB9PbS4->e+Rwxg}XAQN(Y`xG@gaWSyVijQ$KA zpZ~?I=(XcJS~`P^t1u8r{k4he3w^M&5fVarxcsi6%rH=-v@b-FUA+3oXR2JauyYCw zzG-8B+OewqWTx{C z-Fhnv0ygpWJaM_dLmysaeAChu*$P0H`S}iRwz|6Cb(t)e#!g||+bR|i>xDi)Cy=V! z*8{~Qx#ZInMm*n`d1SGh$?On-L@wfe_UR5dQ@6Id6ZJMMEf3`IkR9MT5elD>WJU2; zSl9wwI~*q?Vf=j0#aHZPJoZGbU-p3H_-B91ItH3-U-#-JOo*(t-~#%>XW-YV#(EY1 z$oP=RGHnTC6fGZqV*Am-mhrDU$=#eOqIS3D+G&1-43NA3<&vUf?AV*3nqL&ZVr0)v=DJ1M}z0wdWy-xa4#h^YRxvE=rzw7v*K*X+0T(s+3LD6^Iw`7CXmvoi9HZ{@iRl* zL4J_IjDTIKlG*>TKtX<4)bM95MMd-e_2ORX(&q8%)^_d>Br8(o&7Pwm#?ZP%QmFHd z8rLs>?xX5uF5&i(kZba^xUWT&l;`I98e!apZK7Qw!s-Xub9oNcVL5iihpLro08X2G zr&?&J?@@$|`axZ8Z z79G5ov6$pwUe6iLfTitl^26vG{2S@ zb&4|hpfcF0;i!lai*unfXMi)$dUdWbUh%sdrYCQB>*7vn$i!JEuJVVyhX298-LqF^ zTV@fR4E#^n`unK5&cs5GX?{Qg@2gw{eBYQ0VJTe#r@_ShC=`~U7?x6JZB6xY!WSt= zL-uzc;IN<83&s!S1Gg;s$(NDd%PeMpav3^yLvg;b!Q?sV7|`a~vqx*=+)#=e0Z<@6 zG+h_XSy}H`u_W$`f2U4`$=+Fmt8N>fPad-nYwuC@qqqdpNeyLYTGPA9(wW+1x@d|- zmzRDnmxYcx~QA6MbYVoeiWeV@aNhuFW24@slfYX%{Wiwc8Zc- zeY}8FFZ47Mc&Da%#+R+bPf9>UE{>?lxpd>UT#6x|S|}o0 zxb>)`*1v5s#T`cLTCwo^BOHq>4d$h=nsU4IEqQ(_{&1^Q;f@j(kACmA%WH^AX*h$f zkN+&B8#%ueNpLj>r!J}`O&N(JMuP8f?m2XF{C?C|QzLI31gZ(!Y4w-sa+wE``-+bF zyGsR8@JeQMQ?^$>Vc7S+0vXIv#E}<0+J@L~8m-fFBw@GRlpd}sskewABXwqH+3=C( zZp+C?9X;6gLittxv-Go&6K!;E(f|EWGquPJF+*t7>?ssS20H5Ab^vRwNe2xjJAFwl8 zxSw5zv}!kIJKw~J|IE|=xY~ikxD=!1I6Xy&;Pk#bnAql0$Cc~JtfN^vKb45bYKfr1 zAZelk4}nT^-)gqZ75Hwt&=I0>4a!>v0-B=usE-{aF<0TXqK)jC5ukt6RZrinbGX{pN0hrQsb5GuqrNqe!>s zvXCO->ft1c)u-hDWNCM546rK zl?Wib3_#B45kAdP>8hkyO4OJyAcG49lG3>1pxew{Yr4M9xgU={o()B6(&~OP9TLIZ zOvml|i&i!c@ENu*U8${}7S5dB$G(aH}5t(mh+k>J3=yhl?zAZDU`!8CIT4laHpNqG3*>1qKsu*02R@ ziZ#$c;5h}o?Rt+sRJjlg|YxZ7%f)avi650)iv_uXxYMpXd_u@&|cyyz@RQXH@wrXK(gm1U95#5RXjpwDfEvU zsZclX)z8Sqj{D27K`|P{_&)hEb4n)EeK^uV={xd)c>h_%;&A2A9g%*&wFXH;X9ueB*J>D2DL+>NiD=nH(BKV_9NM$kKSar!8 zv>%r@UBH8OK~E(zb3K|{D3JxSKtA>e4ZZajUNAP+XQ9uv+c{KawsDh35@u$oJng-+ zC~B58G@-7%y%mgmVWP@WM*dK3oBC$SA58Q`O!~;(rI%UFo2_0ls($uqqsVih@z7;< zSt_Y4Zlv8lDErTCNjY>%1u7~^|1I#K&Exm7#)!2(!~VyG+|F*nw0`A4=sRAab(1P> z=5id90@?Josv)P%a{(o6pgH?~UJI+zE=S_@C_V2sRU+gwM|5YUS>DK1NFGf=NPKC2&L(p>>3r2F?%C=36)U;- zE0~;&9~R{&{oL=A8o$EuTd0mKP|%rA6X3^1Jl0Y7aMEi}+*@>8xiF@{MO<+fB(r}v z1!uZ4PgYohpbU_+*k=zu;?732LKeC1W9Xtz5(#xU=gN6KaVDyEJdIC zO$4X3o-yX^u^nR-Igj;r^>)Q7U9)^EWaE{aBpF%_>Z;XB}|Rm6+1oEq~q_PNkOPGoF~oPg=&Ga zoxkHLQVL`UJ*{TnfPCOfK8o$d8|C%9p`#`T;%bAD%XW(|aLN<*Uw1|lXt3o`wPGho z)X#1|EG!#Gaf67^sZweLt{#~cX7ng928{cBC?m{uRYS&!oID{M-n&Xs`O+k7NkCe! z&>Ku+6VKtIg*D+S%5?qopf-D$i4jW@8N>~Arz@wbZlah`PB#fi+GLXVd0x4u?Xv5( zmhFW&M^_WOC!W3=D1!*HvC7umi2)vi6>2}rt#M>h{%1(Zqe^K4K676cert(l@Yf&wE)jfbnAPVN zueH`1@2eWn`Qs??u_;RBSTW**C;8$M_c&G7g~JAuc75*VXoeylo6AzGX-x{zhuWNYv&an zdeP7?Qg2kN8ds|>Ts`WSPJh@rY)as&A%`2(_TW`gLk9QK=%GA2XWZGfjWR3U9L744 z;YOx>L;t(H&QYR1#Ou?zCI$G5cHC63{(6nfi6K~aMD3hH2!AujN^P-57e})%{FZv= zIb18lM>?)QTU)uvva8VgtFs$G?7G5kw9kJ_c4NyKeZWA)U5@l}o4EuFWMneW|cimj8hbRqhKEas?SLjmJB)G%*2z(qO06t zrJ-)AjxZdlz=>603M&t(HJ!ih^e8b$!;IvrB|(>tO{*VfTGz$}BJTc?;qco6RW5d& z!_*5c`w7}l+8ULI&^+3Rzg!$#Stf7SaQN->CJGIg~S8I-H&8-=7d$qRwNxWs*jw7aH{n7m3y5mjwQ=QxX zsTYX}Fy$ib`sQAqa=3tc!Y-L&S@}lA3RuZ;C%rTz;qn4X&d`Fd*k292)bn!6iOsOZ z3d&T?i+;(96RB)%U?>m&_D({bhy>FS8H2wh+f`Y32XfNc+s6CFyLsh*2C7H5Pm(iaV~ z(y*X{lp|-&4rm!{`g@#N_`jTa9XpV9l0+eoKf(u?D$+qeV8&rH9if99kx%ibQS^2b z7*KwH=0bF2p|!Hun7%M#VfbEW*XbScmxTR&w{l;z+@ZcG(6HI!y-UglSflu8E3UQy z#YYvfMH-XWCCc{-U>r#{Ugj-q63`-@3CG864Qz}DewsPC$h&Xn(8Mw_<8kMpIXQyQ zMe5nfH%;{UeP%?G=nCtFW4JD;1SS3geqU$*6x2Bm%3%OIIE0X=V8{swErzMDjcqy2As@Y(#8?!Kgf zom+et{!F}jOeZn)!sLRJI(N!T;nG(FH14iNrw`7%l?6jiPj<1W6=vtanfTOKRv)-- zj?21L#jxA4P7@#Y-M;IS#J?tY)o$J!%-JKr4Lpx*Pkr)ZB3yBCOWo;Bqa02(!^poWInw@6b680Af=9;A%Fc?<#DnhYU3ml+{TKM0o zlhmt326fO11YT)rq>jh-_X~=ewWi5h2ie(tW~=9FldEMEOL)8rhrB|eNu>~I7 z04gXpPr=JgK6(UvQMo=XV^#}BuL4ud%1i}AXs57hPIB{u`ZfH zmE5G}IzkT9p4BM~a`YkID|CTEf2W?G-3OvdYyB`d7F_i{MA4c55AT0y*LW{UGpbZ$ zMYCUyEng!LRrxz%#&UAZr1^CuAFmVDwZp7Sh#PZ_#_#VK6=igQDmZ{2=5%>pjx3JQ$NEW=$<~Zhsxa%g1z^xi-x`j23jX>FY~Y+r0i(1R z8+}S2l1agx2cviAE6KkS65+2N<8i2pXA!9dszt_xR)_h<9ucnnG-M?H83g|O~G^zaASk@d-t!x0La4@$2)76<@v zs#R7ZEA1AMox?->q<#X>faMYh#UHN9kCt*kry7TR3LP?w(50)gn-(9}P+A*pJfbu* z52rBdrf5N!qbRR2HcRLEWvaA)PJg`hsDQZI3IR1TT-M@BwuQ?$!NXXm2r1((4{Ac7 zoyU0h1-I$yYe3f*nPS*H{e@40NUJIEmI<*Rf4v(m1gymjMcP!la`V<}7yWp2(4`T%ZG5wDkSIX5n=nepk_`Nth>W;*mbE02 ze@J+IdCGPE(mIal!HP39oBNxWa@n6GT6*`^A^lkv)O}hxM2sJh<@-%vMP9< z?l&5wkur%?R?VxOeYee)NwY3XU18O@D3wXxCYm0YNy{5*O(InTQ%#>>0!QiOvQM0( zQ6&7~{D|a_2d0Qi7N*Z4bw~|DpRb)Phy7F-)CyA7m|bSIAf&RxcT4^{8Gzcwz}9sS zmE1y4tyCBQ5m%=#tRk+*7=N!adtl(4h3`TW@sP~xw@IT9PuQo`S8VjyRnJrb-Cx5?#5YV~ zXqDv&nhv0%GiA$7a=R}rQ?+%ncO-ngo?An=*1uFMK*HKcd`M+sOE(ic*LR~Df|a%b9aa>k*?6;s63mD&yL(K9YWb4q%e_2O>kPK z2^}~gD<<>re`{k#l#%}UX^ueD7i@4;AdAOu!>(LQvb0fkBIOTSad4&gN?R=pAy53A4QGCPzy*du{`;x-Ti5YPTnxwOP)CMt&rnnv!-c(!Vrm z2x!}!@dz^Jzm?dRZ^^0d;3xDjInaMkE7Q*TABOUq(r<8dVD-H1r4E;7S#d&uER)SF zz~b_q0uz*4UV0oj?SIAQg0{il)k#;8iP44JrWdc;W3UR(9}QU{L7ACf#j8?~Ne3U` z!2E^Jt64-K$eZ(TMI6Yj6j}6^J4erE~gbEnuxPD1D z93t7eY(RY6XvbxhxKDOJw2FMyNz1D-=eG1w;j@oDCqRFYynqI-wg0v#z3BXD4TsbxMN=UA zAD`7`hPu8DOXOog-<+5!wY2eLhh+;(NUg@UYPJUwdFvndD< zpiyZ3hU#vU9(il^(Tf8sv<2G7DjmB!c14i%_?@8c1nW%vcag?@-g{t5p~iA)6@4d0 z0tl@ppkhy^BC-!BiIAm*8=H@STn=?Z$N{~*yxTpd0LNq^8tpNH0Pal)9nTgTMhF3z*&-=w~}7hH{GCYZx+ayB^G(KZb0zaA%w zl~K=gk+%Wq>6faWCPNk;Zwy0WI8T;fzdE{~`0$NC_*uwo=XcC1vBz+9$_SWw{>~L8 zb%gj$!8`Kf4*~LzRE{xqAOMT(A<#rcYfkEmV!bK*0n9eL};#D5T)t-g6I^` zB{Tps!zW_@Mw=Y~x%-ex$1VqXV*XgvDNK9ynKs<8{LXYQM)Lk@qUt1hY!$mbmDT#y z8btvL89UJA@b0Wyfj0a0JuMlbP0TOVQNy#F9?p~lY6+v*cfn&aopkO-u{4~4#)|rJ zX}?P|s4$@FV-zw5n7>W0Cq3yw3{`^r&ilfTj8Iy5!*NG4mKq~IUO|dV111POK zN8s^ICs~PS6=4e1V3HLA`NQV@OFl_P8F7l;zZY6?fNG9RyDErg4J!VVh?{AM)fNQ> znii^t!{i`px<9LA1Lacznt<=z*-tQwGnS7l`u&re?p3mL&EbR)k&3ZDU$0=MO*}=8 zsbB+=W$+WTA$-e?NSfbS-#Qi_koMpI6BejsAzfXBuYGo8(_ZM-NP6tbZ;3WMt#7{3 zW2ut*d5^X1pBXarTsU{1H=-wSH)$84QZfx5>~f*IQ+13G-;eFd(l$_TC;2y2A&%Q= z{(=;SwmjeU0XAWK-Es)5^V3?t1fTQtpZE%hSS#|Gixdzb_?W4fo0CeBi3>_SsItgkK{kXp3|H&ZJfT zXcgE|pTr7E01%0)>A42Oud@pQGS-YXzG<_iJD2h$34u@v}5`yWD>=dBl7qf7D+89u6{D2fmUC9|xO{ z8^FA?u^>>~%cMQU0ct6E)$^3;Afsu>54qJcKtq z+;QKJk4${%`}%U1XDS?z8yZM`?2}@4jwBi_;HovaEBYyinN8(I0mPnjzy%-_DPeTw z>Vp09TscZ1waC?Mt0Vz3W;C@nJ7qPe=Cx5WobqB(rh>aeoP+7}6U+(e*dpzsHxJH( z-~Pehn(iPvQ?FIZUO^0{meB%s5AuTN(kr=!*P%g7x5Pp$F(0`AXP1l-D4@WnBOat& zgE@_RW2>;P@L{*i9o+CfvyFM~AmZI`+-lXL1CG@6Pc!8nRP?=pJ!N*D+!vvGEUN#P z|0MnJ{qb*HTUla9rYFwyzXb8(<0pQKo**g)&}WW!F;`NswueQKQ(=g;dn_aUyJQ-n zGx3ERH}0GunSc2; zj6wf8^5xr6Y=A{$&vN%mjAnT{lRp$>n?P$^=X$(}8wvNq%J$*Q3E@U-{ z&WzKbQ8*!O2{HfJ@RJ**hQ#s~^1Jt{v?kbjIxD(m8fC7>Z;<-d&Z)kvLfRaeGvQFG;pjrJ zPbCgkn>s@er}`)W*8EFnnEq)3gYTAcAa<3eWK}*Llv2yw|G>p`_$>9JxHXT+ zVo3e=_ZDdj0}Xt+;9exX1mpIXk&ed0~Fn%)@@& zI57e@ll0OGvR^xf(CKM@``DUgqC7MM1i3ld3IfwYYU?C*VuvU3KbwRPtI5uge|XKR zP@dDLIEiLd`D6TIW}$=9vu|Z1OM07~Vid+c85$;X42785ji`ZyDs2JE+wS~sW1tlw zx#s(L?)#y}+=O5OfXvBp0R7u~H~u&Q=iNH!9> zbI1K~-)3O(J|~G@Y(y0DJulk)MM0WekVcDXBpaKxOPh;@Xek3(d37CqT5Mf`wbJC817#MD#`m~lJ_126a7S{Fo!bvp-;ubM{dYqb<#K?|-p+xIL{M_30tUHI_N})}ybrzHnK+ix z&@>m_u@qniCQuW**tYsL4L(}M0|Ew0;dw14hKK?g55-WCsAP`VTj5XgEyiCY#p|?uiJOX z6m8p`*fB2gJG#<00G*RAL9Go0Tg- zPox5E!dE8xoh(9!{KOBDw|bYp?Vda(yc_9vu$!UDk%72Jtoip_lsDoWEGxaSaP|7h z2$YRv$fF9+#8pqs&4hU;z#wHX>_mK(U0Z!IDaL0843;Esf4{CZV4ad%4t70bkbiq1 z|N48qU2?X}C_(-=60!+J-p1s__;Hd22j?KAMbIMk2dI}&KShewt2LTK;^Tyl)Z?kR z;JwJ#%y%E9oXYR=!aIKsPO^nbk0V5pVPBVLIX4>YLW;-Axg$EqNfpl;gyeh2o-r6g zMO`urO%{F^0(2aN>P-QeCm?BqoIifI?%t(AFSw^n4EHDy?mrhlLAxcyv8Fp%9Q)_g zGAEnQN1ob#oC#OE`yp1JM&(`J3tDOdV8eLh=7|T`b>9K8R zb6BgbQPfN}Wf#am3c)iD7XD4mm>BNldtJnR6O5fn^AAPa(r1<2`>{MH33RX?ts&@q?H=hQ(m zQ9Z|+9Q`|8e~i8{1Kgv;<_mO8Wb&oXJyMKq;Mkshr~YZ7 zt&dSz^6EW?3ekBHpG@T9t^Da$j#h4;$$g%H$9T@1x#T}029USV!@Xy#)9d00V!o9; z<>mBH#`!n*pv^3+9+Q*&b34ZqhRz@+&6&2vm!%3!A6ge5P1?hOZM&>H1UEamO?o20%1;i$6 zG}G+zb#Y9ppLPx*m8vQC+gSF>?xrjm`;pmB;gbZY(y^M`Q4Xv`{UU3?TpnQjcY%t2 zAi`_|;iF8~9D8xtVQDVLaF=^5knf-l^jTv_&W((8Qr(yILpE~_+F)%i(ZEt-AGbqYJ3S&HW0jC*1`fh4CBu7Y-7|6YHZ^i+hd!X0eTFN}}p?8TUNppgi@ z7;e7X@L$*Fb2ul=Ct=jMIix0*j#sXa$|5zP$pkjib4P9#VvYt}O03^L3HX|VjUNCE zZG?+H^`@>(Yte+TKy^@?>yiPA{Bf%6ja9YkpdHKBMkY_%n{P1CsUv+0)q1FJ;YU1O z%sZJ-M`e>x0pUo1`nPY0ssV?(s;$VQ>ZN3=PUgvGD0g zC~)HxkQsY%Q?W%oZ~%g%Ej@~O-@baF*iuXW5=Gjaqnaak7e?of8^?40dS~I1B;SsM zupaY9+>HpY>g-3-LKc9iBFp_Ixi%5i%4p4c%JmuU-bSJokp-mF%h}>Kyh2>PW1s`2 z_t>)7$+^YGJxd<0veluURV;ww62TvOgu`6=tN6}JuEzLTr)l&40?%0na1jcLV?(si zM0r&g8TMslZinbis59Hpz2TlJyapn2C<53;58FJcqzNA}x|f9d^&(T{rpw)P!M?^< zxnX7Og=o&A@LZ{O}k_*p!DK;p)b%+-9|v|E&%I(_-l2aS>8jo&;>bL|}d zH4wo)ZUYmgTLjYuls;o)Fq~F(u74X~=}f~ohHd+?qIvCGHXo6|3UttmS2P2<0B)u; zZ5uT=wFzWhfCT9%$PHBX5)ta034hZt?NnVzd{h~3buMiPGM}tDZkERpLjm%t4#QMo zGGvxi4XS22!?p7UYdt$UcncwLAfjKgA5SQ6Bi}%zoJin!v4kYkLaiE`+7r0WCHwJQ z93)Tj6Sikq5>p{&xCEjU6N~DG7&nkth7oA?(INxg1pw6u z2hLRK-h;%)pNVc9j>tD*ZsjT9P#@CB{pJ7D-CMp@xrJ@NAOa#OEvX>g-Jo=Uicf%fYJ$oPT^B24y_BvR8@X+ZU^B(s#uIoI1ClRdiOEK?% zh1v8*lF6E#?$ex&FaaM^z}}<&i-|`{#Bx+Y2O^*h+S`s;l8mE$&2y-xQIR}9Eu=A+ zEj4x>e4!6{2VCNHlI5??mw>h26mb^jhplP*1r;C?6%e>W zr+$jVPE$kc{HJcQQGxVtcT6}k+^qCjX=ZnA#P%ybr(f=j;9TKe;l!~Cvf|{9$UGO# zu?IFk2?YkyDXO*I^tcTFGYN5Rl;l=!QYI(13 z+6h&8?W>e2yXc`1Ml@1jx^OR(2?i|+-94O=>CvBnj0q?422>Z27k{&W=IPnzJx`l} zr7al?$FKkJ5Z_K&Td2`H07);#F1B+od+mQW^A&g_FyksoCB$?L31!ryEckWJ3AzQZ z*F3#gX?%|>s;1)lQZg!A0@i8=ZM@jR2*XNjHlM4_^%2t((aZ~=@^nekF4st`xH|jy z<479l+|mrWUX}@Y&XC7IInz4jvJx||^kkHCnt-0!P^>9$3=Xu+iD3){A87KjB3)ZR zL*eo$TG)JkeOupbc@I)-x^3YHE+^qJ&}qjKRc&ISmQvO(qbK6=!`8Z*go*dXIc3tu zeAq+D^kykb4jl)K!PSQ*qkj&Z08@_EBDW+aBxji(pgqHW++?8~#YtN?xq} zI?M|Jb10n@Q{iZzO5CV-K|CslE@dT^%LyOeK5$>ur89p>Kb!4l0~eY-!g>X;43M9H zg#cynC$U;dvmJ)~KKXYXP!2{=okk8(%bX7Y<$}cP8Xdkt2yX7D{!WLnn)Z!$gJy@) z_mmotWOEW*;O+^HpN|WyBDoF}c#l(yM0OH_*m|_-+uTF>cGEd~dH`v2dRci&S~!SG zMQKl**-<;GE(Dh$P$dEj>Jfsb<7TdTAY{NxNQT#t?nW;l*hPUVI!P`NTnxjIk8<0i8mhB!56#^XkQ}&SOvpcLBr$ z2=q91Y(|cBEq)uPO#i`-tx0QyEWraE*y-EEx9ieq1@7t@6YJPo3>DZ%WT5r%`A4h8 zGZ&?zc%hi*RvFdz}04ieIe{My%*GSidV0N(P1_l?w395c5PA z(EF_VE@|t}DoXABRV-KPI;l^Td9-m`(oYqHW%+U`ni zB~V>T%OR^w>G3LeAcl6d{6nHq#Yfw8wNL6MVdQY8yvGHvf9d~3dV7b-+h1Dqq$V-g zaFf%~CWYY1B(OxGd@gPHym-9D{kOX6zozFB5qm?x{|e-WI-6)yF6-Msel}BFxX5wM zDnErcM}W6k+dOR5`&oaIJ+@{M=&194#-P2Lw+Nzzwjy7s*SM^Zbh|FFvbbswz04`; zF{ybf84#!Fy%LAmFZrf-N4PS9hPc`71(92J@8=V7Bzu%dGQ7#No;9Gq3IlTjIETQ` z$ybINWIk#CzQ(h)Sp1$=8S|b5g;RsxdooK_P)l+uCv3chZ(L%Id^vli{5&Tnb;-;a z%pb_VtiSv9CS)W(&RXGakC2Q0q)a_3nWqbyUPYj>);Xs%a=OSJhYbovE2a|vV%b`$ zXM4V&15~FDmZ#rroXKZt_;vd4+p7xP+G;D_4pTvT# zB91!~r)1*XSUa@-!fl%IjCjoX-uIW(RXdt{69Si1CZU{bZ8&r>n}>0cXtMDCI`BYM#9^J@6rr0JWH z^$o)x7HBvcU0(5B#tYRUekOgUq$kv#k%p0g)x!(8faiF~6F~t^Y>aa4>wdQ;Y z_COax-@j^%szK=1ErIC2mixszwpg9A!jy5L%dtKlg(AvHK*R`I)NE!jfHs~PBn96<5dgu}M$ zQ)KWRQnTl*#YJ^F$COK~EmLSZxE6oqd9#B5_5zQ{b2#9{u+fe5*c?O7{;{O;57SO0 z`rqkvyT5abR~$|&ze)k+SDnx;zPd3atAkfxKWO`+POz?=+Ua)B=2q{UyVbSU3DcHK zVgDh=+bp%`0rUQagfjWf$2xc*@Yk956IYRq6iAge$>_L&*1}ocIeq+Z360)<1$RHE z!~?P7N|QKHfLo&l9o(hqwV1Vd4sct5y;R$tDe{RB>~dQ!h`)U#RLs4`P_)Ia2JE9h z=GJ<9X>W8$Aq;Ow)Pp(eIGTxwxsu1gYtzOy@Nkq>Ox$bYa&1d(O2d!1T%CMw@wfRBUYy*5lIKiP% z7B?G}tf({ggS+Upn8q}ET-ME;-Gk;Ebep@W#AR`{TEBCjZWWj6ugw` zY+k$0vVm&R2(Xdz;>5Pe2=8nlw%b)Sr4k`=!A}y?d3caVEj;750V`z2LcLelLMj8zZ^3nU@O=iy@mWUbg-oW zRF#7Pi#BuKeo_R561IVyPUj&aDL7c0Oh7|@`AvKv;8mn0=<{I9ogOUhPQJFGr)sNl ze2h|dK!ul$ZjAF=EQX~)` z^t^<48W4wC@meWhkH37e-#Yd2(P)z1TvM~ah+t^rWDJ?c?244QGZ=%zZa>bzwwW=4 zGV{&JLmh-%qwhFe#{Tat`NKHrN_o(_dg}}@E1%B`qrJ8?q4?U=N$AUd=>Nsjuw;P4 ze|zCc1n_n6n7%7$Mu8{nGU{=cgAh!t?7cjWNYk-0{4f)Mp;WjsA9+u{)Z)I5VKyET zE%@QX(X#18^#w3i1%YoocZU97WT`l$2`EKiMuhU%$jv81p7r?0&9J=}65M^S;J{4S zFREZjLBVVOM5w&Q1VH`qA&W&Sdjs#ft=EI+WdtoI1mRs;PG`{;%gp@{fR09(1^ zKx7Nik9?W&!=HB;Z2P>HGf~kPj(}QLgqJ;hPR%RKr3C=!nir?@u;WyAxD(Hr{G$Jc zsz4UfErG$Mi=L1{d~NFIA5Ct|szZ9!ef^ic$A-vPtdSbD1t+lDH{Fl1~{s`-E3Jjl<5&Z zaH0Xo7|TJ5Wpk|*{h=c=JRGnFJ=#su&y~m*xF-EUMs8LfQKn1Zx_*qgbp8#4c1W$K z7wvwsrKb`WSol)Ql*l}X@@ZBVO9QHu30Yt>7t*Bx<&Wi2p2o@%PpNU>?XEZxkYlVw zV(k0X2w-m+2na?haYxIg;RmO8k#?Rn z$+|H68p-lb5j!$dAvF_mEcAX*?+)EjHzcU+;~yV%PYT8lb^ipC#*ZQlg zPA&}e8}=m0b}4s9@)kGlQs&>yz{1exIsAq=Ga@*@a_OhZ9=Z-}LMZNVE=^?R z19qW0{wyWrhW$xJ-)@-mHHO$!sDDVczI|*Rf1T?@!gJ-en$NwRK_|EV0**TcZ6Am; zy0WzS$j^a~V%Zh}!HObgZJEfC^7~Nv^}H=Uf^dD?MKczTxBv$@X6rBcj#?|7xr^5) ze;Ahu@6M#(cGDRB4L!(5B0+Y?_-@Au(iJ8ye zx&y&p8`oN&9O)A3dNZZnqZ4fud@0dWjmNjaX;yk*NUC75*XdQLBh>Id$b!B!h;J%Qkg|k{VC5 zFeH`c{6F&4D)$V)PtV~`3)mjsVAgG2&?Kr&EJgL`Esxn;_qWM7Yac9>%lwQY2v&?S zo6Exck~WCl1NkazlijTi!_!SgiqFSGP5F*v9a((YmOLPm0EqY)EVSmnbuM~gfV;EZSj<$PP5}n{VwJ25*9s^Xs-!7*y{OK3f>c2Lz zPsR3IM>Lere&h1?e^UrV-8XM3?GLeTDuv19h+2_WNXC#%uK%{;w5U$(AEsZPfk6;u z;>en80v_OK(A-@Vl7K2|A9e4JEmN<}(hGDXVA>~l6*79aW=hA$i|)x6M1+d>Z*gzf&m#@4Q+GT%Y#_Ur93Td02`w zQxT6JC^?`D3{OiIKrZd~@SF3g`y&>RgOY0>bO~q<>O!-yr|aS3;jPj5FELXzR%Zbz zEQsZk@`x>cTqjWQuLp`Tw^as5h61?9wGT(9nflI0S;VC!i+=ECiGLug#ip0N4=En7 zrTHiGhVpPJUuXZgv%@idF~XfG=l8md*|k_YR{Dw5F*q6CWxG|Ucz(So7?R>Z4Q{d! zjYJ1;ss7jWcXa){T!V5Wg9YBt%?rMdUuhy_H-muZuAMZw?+!`tz5$<`+6^jtZ&&Z9 zt04ou|!t`za7#Rvv;^4HsI0b*0=;ZNj z0%N;WE$fo;7qmjc@Pg}dY9B(me1`U~&YxB)YI5=K+$9&h6*;wU5L4GVQ&5hqyHRe- z0kF!FV(}HGVZmt(j}(+k<$UObpj+tAl;Kt%fVMr|tegjAB>i{^1Q9$lnjY68xN@fe zyk$fm=l`J-VBF9N`2Xg7bp_xp_eKOL$9Fj1em*yjf1ZR`-KG>HfZNn7Et-Be<`*%1 ztT(=a*-4{~eqEh)S#0}o)os_tQ#ZV%i&)y^?gKzR$}!RQZ!xPO+`RhuOnaUI8S zMm)Wt@?O*Vi(Cg=(NG2f$&Ra|RlUmbSRmfK8hs92ZLszS0BV8WlXiq2nyocS z9JGsI^J?UIf;v>Ubaq7#Au4_w@w`N&>nQb-B}KAcqr7v(#|^E%kfzSXVTR##Tceq) zu5Z7>Cg{s8@8B=9JqR|V)N?f?bQ!M?fZAZ;R@lYbdU=~H5&aa|LQL8@iY)Rau$c17 z<&#|}nI;2q!#Zds@xcMn>cWIY|F^6gYY2zODEX>iDhe}$DQl+sx?VAfmWkz~=c5zA zUM6R4N2vDgfHQ}-fK!)rjyNUqN`%<3VGwJJh< zr7>;cNX6`~iR=eZ4Y8M84v+K;ifxXNuq!Nq2I#|b!j}8cEW{y zK3ApetXt6pd_R{r^mALbY&)Q&SPB`5yy=UXvXW>|KR?-erku<7B^jPZ z3ypWgylV!(L~ia7<&@wV+s8b1xR>KWL!aQ&w*t3bq ziBzZ%&2NAhfR?0t=d*oFi4vMiY;AGjJ31}D`+4=C7ZwHAM68z2To`8}va4*u04Pr$ zrW)tBuK$C@lVi`1WkkByPN^fG15`<5^S~kgwrGwWN7v|DtMPSU;_{;-?6%J0RY?^C z&e485yKwU|(H&lJQ8*r7_;rzk$6fXEiXXAt!;J$hSF006R2gqzl)prt0G@EN*+K{TojFC7?fPxvFK-6GNHyeqaAi7+%o!`) zD;+YwY~UHrIG1A8;}jg0MO3IFs_4zN*C~U&)|+!sT3*W-8oYyNMZ$ZDv@qw!5%ub| z&>pA$3-KGAHizEicT6U{f9<*&-JJCxI2htMurFMBh_P8D1yU5wFjDFfr%83;MTB!9 z3~K2vrFlrH#eh+ZouM@un2uk}a6@g||PvJfBe7ylyd z|1q{3yWz%HJfXRN)j|@HtuitL_`{&-&szzA`Iu8BbPRRK2n?s{8Myg4g@)uhTgeZ7Rg58K&1ayUoSh3)%lU zc=MB|=O`V+kT3F0V8ANr14ion=F*Vc25ghWunJui_RCQY|9ea7%HqWIS_<^NfR?$U z%&)k{qm?XbY`TONu7u075|Dx+t#x+Fd7yt3O*KgvN>wS4EU3fL%@=~M1Nv%Rz%D8j zPLU*?3oxgI`Y6opG&a>lj;Y$w!mrNmlmOIGVhzei7+4=rG((?8RG<^t@R8aoP31U< zWf-iFIr<|)XfH!_??{>OJiI7z*PpmHI|nroglXAtecmLmv?rc-#^RTsBE#v9cz!yp zs;h0X|n4h4$oMa*6Cug5nmSC0eaE7^|UM7581wPK=&oL&77D3ut5XNe#bZQJu%{* z{sl8X)eMyi{8e)TqUwdI(+l&e zL)zOBFxMDCj zP%OvpZ}oU=8>`=c=;*5Z*D-h#@tVXnpuGaaT5A^L3l=ZA#P?V8y*u;BJtG0|@pLsL zjzUKB$=A@yIYevEa>-zHYElJO;@=ppgS+srrG}0HXi{p8F5;a^qC&o=O-~a=YIm8k zQJg}cQ6PRdlC2!xn#>3o^*{C}8FoJ%-XW){VJ*FR1WYD!#d!C3H58z$bg&*&EFBk- zQ+<$c=0!A}0z}jzgmT90Nu4hA=!*0VGNnQBZ09>uS~;`E(guIN-3k2bSb(&U(I1MO z;O46&g}GMM{sv7B}> zA0=xlt<>m$7pt4RFX*F=Z9A}@&C4!L?vNefPKmLteob(10|dheNsJV8f}%+z*n-h& z%P-^MRyzW&IV zro;VXI$;#2K{@_42t30UDIaeUMZo@gA!U>zdFXBO2yi&R)fZGMa3;~lO%?DAe&&f7 zs8IM>KkI#hBx1A99QKmU8ZC`ZIj*Lr0i=Zuf1ncg7wHoN>amzqfb+Y+IB&4uQ9Kh=s3HV# zzXfl&}9*so#PZE85WCG870wP*P!Zi0MasnSg^J7j=s=7%84a&{r>W@&6G*C zcKg4Fv0KW8w9y}4LnQexO_o8uO*9YFkZ4aJ36>?ztZz&T_5kuUBCj{do4|YNUFM0#GY}I$kT%`N8-r^ zNj2kPxp}Y@95g`hhgVD2qynhHMw-}CUd&%7lC*u8R4TmP=AwRaAwIRv*$c@?aDcAg z#-MVo5LfmWXNeWlG$FdDjAz>+wO466G2=L-ktId%glKdt`VekxZCg0!Iaq@#2Y>cE zEgLWz{NWe{|0-b79|MX?r{5NeLE;@dYI&A+p?JGSq)Bm$-A2RE!B*U+H6KRr_xnic z4ZeF|Cz8UUBgA|8N&hX=P5N4@PL!;(l`wLc*L&93zz>BT{5cqZHU8Mk&MJ5M@7>Wq zMFPs2;Mixo+Lv3rjB@&#I*LCG8|vzdi>$9|M7n6Ot#VI2Kqdgd&&wH4CG_cp>ZHfY zg17JCL=l6p!cH4Q=W*T3S(Cgw-x8TpsMKX9MLy4?zV7tS;HCHtlpf5G8k!24ZnV*19C-)9U~y*5 zYMoG%2u~8#&4}v6X8c93KKNptP>=`FJ(PO(Irz>Tg%s>crg&?Irm8G3=qyuenn=@G z;WDLi`iKJUI*-gJlNy&ZAY7*(THOZaTivNY+odIIdeYW=iNcwHol?2zpF`C#Nyk)P{!H(IKc!hrtmG8(dQ*uv?Bg%k8Bv$*K$-oXZc_Tta=U|n9yFqDGpD%B-HdlJsjbjcYSHa(yjr#+jF zA{n3C!$iHl!aHjG6EfVMc1elZQKoW&nkt7`wgK%SmTW2k#%3HQ&&Sp2iLt)`dy8p` zit(emDSq^e4is!-vYo>h-(;i7J_@&j022Hc@tN^C#U$~SnwOOK4K5)YMd z06IOuA9rSHtex8Z{k}Z*ZqhsN0?;y8iJ^I7vEcazpk?MpvgliiisFa+OL*kE#36DO zJl>|!#u|vPTT<;Yok60A+QQgU^)F>N@OQ=GSLT;atzAR=Ta6ySB*A^Z2|hb&o5PCY z+PUuk;w3TC8fr>p&s3f9qgk<*9aNlBCS;E{=nrQ7O#*Dj2C)dQi#>vp{5>4{LZWg3t!g?d%wJ zAn~iOY~r%%t=2ygbV*-SyDV`B=_V}H-Ysejt%*F8!8f3$ac~_wJ*y^yEmrmg_h2iG z>f6?h(?`@A;%Db`h# zvQ>}43u&8kt9`i_hWx-eHbyF4LXVhA#0A%puTgf*2P7B~VR_{ow3?#_%a)Ay5AUgw z75T$~l>CV>t+PH#Pw(;qF!x#vwb&ZKvS2-7LY>wLrVlL7O}15vp?r!{_eaTt&Jk3T zaB$(W@R}#hIz`uIqS~di9oh8WZROWO@P=^;=%v{XX(Y*~s&`t|WP0Jg2_w#6$NnrL z3(TY?M)Ss70V>fl%Y0f92O|mqLtl+;Kx@ap%A)OYRamL5q?%C2^}2uwH3f>d-5B9h zvFig9sRRTu{anJq#NcM+3ZN1VgsB^U0RQgc81B-A6RSq8Zfdo2$%KS({O%UlQ~s zY;?FODxD`Qp#z#b(65YYqnL!^vbPx4pp7}J>@e(e{ns9?Ag=WI0{DlaK(d7x)gg&Pcx?!HUcGZ(vZ?sk1~&WkqE9ncH}2P*GG%~ zjZZVINYy(2Abo}r8BiAhPg}Fr@7!v7dm8WX%!M5tOjXiio|6HVC6`4ymnV9SEMiv@ zNq($VF5LZC1G12gy!My;g}3XLucGfLziyn~KZw=Z&REQk><_OQi-3TYa*|XeIM`-a z&hG+qQ8)_8sp#IWm^f6U;OGMT>`5b}DoZ zEqUI_Jv(~TZXhAyy;1l59fB$xScH&(8L8w>j9o-xvkVY5l^5WSv^KhAF|8UU44Y{N zlsR4O${Z~90Y;Rs@>7wRHX+Q|lilboQLfGf?0Cj02ivW4ucOY$qdj2vx|@>Yx+?@e zi+u4FK#qabiUc`FCQ=>iRg~rNtAB8UoW%d)CX(^HJCBQH$c-Dlr;iGQ-Anmz1(yJY z{%RBn9uB~Mw;&Hi8%Z9s=G1AuZI%JEr?0^_GtvIJ&-!mp7^5iI5bdaJBC{kuZB_Jk zPrKJu_GKyr@6krta|wWyuT#KPJ`bvx$=uK_mag_MAZ z>&*{3yoFlFXR`NKi58=*{DTlJ2d4B(scXPs90*t~SSPb#Sa(GJEcXfj-j4tCoD04{ z_X*xJLo4I@H*?~?t+=FuS{g04RDX!V0}TaLaOjVy5)FDOO-Ttb2DFWs-TD%40g?<{*M^ zga*{rpGNj92TBU6Kev!|Ca)v-l{~{Aig_>RumB5ME}{o;rQBTEO6j}(4HR#G`DTQt zYM3kIMUe^!W+ApSyygJCrme7u+$BwyXK)?O$Z4o(l$k4>-(=6X7P3{?`ZnLaDzEkAc}OKd7;n7dXUI*|+pnsc2s{awA%ApydgnJq{Cy*v;Rje@q3mN> za1N@$Iyp}J(Nq$P1qIKPy$wVW?roZr;;{Lb&}Q3YI0FPy!j%T%WdX^{~& z-A}=BTYg z)%q8BwwE_9&;A=TNzDs!fkFA@z;J4SDbsL12kK?%2|vkh0%f#lkHzB{{q#qcet-mL zkzkXP^uXu<7#}V_@=A+V2j*z|s0K%A<3Lw;$FzzfFacy1KDgabA`u};*?|2I3nrm# z6a>04W^Akvq>(g@?b|NyKmT&TmkPDVy#PZr;E4utTaK*hT_WU({|x>pEw@bEEC`IP zBf=eRSD&2~GkQPHtXho{igCtvn~o0^nAyw`B`cy|tH*4MF$d_mZiUrK67Uamk^Tmmce=;RqSOJ#R`>Drsy7;4 zg~~?MX~E?ZLVUp0GK~}C*GYSohgzt>9p_XQWlNQgiI1@c zg2!4)XGg)i9x6BnlqCV{OdG1<_|-b)AJ`N6Kk|nY1hD0vr5aFo!YkAF;n4XuY|jbx z+w~1{Q>LScgR~N4_sm_Ll=^HGZ?B$_GY<@RdJauP?~u}_JvDT&T{~nReS!pI^h^{8 z>6ANyhUXBSkMN-Y263hrN}+He@X^Hmds zwKse5(0=LgO+z|{vIr+guA^hLy&VSBC;jg?ls+&TK5n)8{nY(lSt@R|==003W^)p3 zpuq&TLrZ?|aJ^T}JY3e_+Oz4;p>zOvilM&fuW;6t6HuE_;x#dmvkFW!>5{ z-Uo(JgV}d3SKksuvF*T(#tb{(yL8pRnJy!}(PPV)1>NOMqTMdI_apdAG!k)ZXp(u@ zFcTa%E^6kLcZygknmJNhhYyV}aSt0ylemPP##^SZl_9xiC=%)$$=~Vo^XMz@_gGwT(-Sns*!Dx; zUGGUCh=uWI-guek_i@hYKu5`wI3l=-U9VR%ab^>g!v(TrsRkgtIYU9fEGLEn(7(tg z)qm;(Zg;(LwHoZFDd^wDPkd6`MCHnWJFZ|6tVhl;Ej{Fq>{V@f_e@=9NbX5M#IqiI zW7N}No<~{LJ>`tV94LxGMjViPIJ@9e;{9L>{=Rub1#42?7b6$2J}n;(oW}HwNRrxZ z*RF=>6EnN&tf&=Nh{Sg-@4qhIC61<^_(K33@i9oq06g8|9%n5h5bu_CKq1vK{DFbT zzE0B`-PggDXHpS*GwE;nVLJaq_<+=m3o5>8Lyu7>h*?xEL{me%o@EkK_ZtP)0)cTz z4`3)hIlCZ%rTMA>!S$kT^OxZFv#TmkokNhX;A}+#cD@kbr)4`rEcKw9Bv&7hlEiU; zA^nU4Tf(!eS8Sd>&B5M^Ha1O_JXNu@mUn}dmaxrnf0t@@pU*Go$y@oI9a~<1`F8_0 z1MkCPkB^EqgvZ`*UK&t;0pc#g4yo{%7*x`DzP#!<=z>>`+rJS95`i2PGRDQBHKVJ| z$5W@CjqCdm-^>4jMbOQqehWwlNA_$L^*2z!hip{!?qC5m8sNSw7H4lsfHuqsH7VIy zaA&7ORqtoaGW;dKpCsjYt!oz2v^^Er>%EOR#_t=sUs0tW2X8^<*Bb8^YNzONr5&TT zo{gJrg&QLV=G3Imm=x^JLMlWwv{(|FmpeZimGA3TyN$h%I zj5nbM!-(w)cY_VN1|oCn5q!MLCA9xBR3+@O9{T&)7H@4xq!Akdf9d{Yw~*F-`CNEr zZNqUt{qSItam`d+7ZN-!M$#6Z&@5QYuODj2-)Hg6dgFC$@Pdp7l_30xRTy^VItSG= zT~L*tu*@Iq+|6ioh*TL=(J{S5TX3Jkw)nvI{v<-AspE55oxXXkeqjBTz#qik@6dsHt>Hq6-hQrMrDsw&VIQ)hf$l3=B>i+7qUQkD7B1yrWw3}kP(XWk5IGXNpT=Iz9SR^SUUA?Q_RxZXY% zLE7DPP=d&Y$gaO`*_}HVETU%3FB^##zF!y3d2Gcmf-$vRg#7CG;|a}leg#MyV?#&i z`19%H5^;3*sN1`@5-6vfcV6fE1l6-F=}8#iIzRRZ=O}vG|=`4qq!!9&CKS3`B&hpA|rJ)Qm{; zFq(REY6(~h_ULaJkf3T7n+$`??awb2k%)={#JXu7!c&@^ zh+yIH!qvhd%4lA_dh?+j8?jcQWcfQo6{`jn!}mx)}^o1%SG_j;5@xDE#qhFe-$XE8>IY>K*X6O)}u# z6U1JWBU~SDGk(r%^sm31Q?`}51>tJ@7fPvPAg!id#$b+l?0sjm=aJ0|5WVE>y+vDZ zt@a|fnwCkJAatKgITU0;T-S?DN^imvZ}5R|)dI~KogNtC;@fs7V<2W;GNSR$_m&F? zKN^>o7pgcPtc4BcMYJz{$aNR>s!6Vak=|UH;b!sBP`&(VE(p0j_4M=;ne>?MKs3+Z z$*B6p5yvzd%y#16-9>&S1#9imxJ5aAkqmxM&Et*^1}Q%!adUbKxhFrlNP+&pf0?)p z_oY^K+kV#Jh;)Dy43iMPLLzlQJll+{H=L<VLE+ngT_g`Gh8 zTNp4L`#22r`f+Z42w}mafcw0xa0djm>!N8g1bmD@tN?JFvzUq?Yr5OnBhxguY}(oZ zAd$BKO2#F9UDr${tak#q=ElknHkMzfX=^;s>3K-UI%LU7cMqGc$n;zBw%e&*2i~J&8sHG%_l&%+jB;e1Jc=YKKh)(LSQuHJ)}E&90>QAPM7?E`K!wh45?OS{$5JT)HLH<8^c?~0DA#-{)`LXXidu87Y+BEV%{zD zt&zsb5=n1I6IN$T|XhnaUyk{&EHl=)c#T-jFCP=-^KL*UdsM(Mgo_RzCloh&(@E)r{{{8Q1>RkF z?l91kAaiK{nJOwGDDE{m zQYnVu(}683kp4GlKFsf$QOSV;p8jc~dhBB@&G(gvQo$_-J(jF1D-cagm;OZ|tAi-8 z;-9>9{U zI~xBfF>(ai9FtwwJ-h<{WKnDV?`3LaJO;vy>H7mG6PD6SFiu&V?^;fi&)f3c`f-;I zI{L^Asjj~sT4nc&a4m!?uf%A9tU7(znJ_NQjOt=bwTxVYY3rNpva4XsTIHusE!%Tf z%jdRMB(K2q9e9vCX`f`2Z|}=zFO9X3Fc5%TBiq#~B^f5-$UXyuUT`roHE9P}DZu<+ z{C|r(YGU3Xe82)90)B!Bft&#Rf&YF$%h3~4*Vx0p&(u+Y8deU010-5 Ag8%>k literal 0 HcmV?d00001 diff --git a/omsorgapp/src/components/Header.jsx b/omsorgapp/src/components/Header.jsx new file mode 100644 index 0000000..bf681ff --- /dev/null +++ b/omsorgapp/src/components/Header.jsx @@ -0,0 +1,52 @@ +import { Bell, LogOut, Search, UserCircle2 } from "lucide-react"; +import { useAuth } from "../app/AuthContext"; + +export default function Header() { + const { user, logout } = useAuth(); + + return ( +
+ +
+ +

Guten Morgen Sabina & Malik ☀️

+ +

+ Schön, dass ihr heute wieder da seid. +

+ +
+ +
+ + + + + + + + {user?.username && ( + {user.username} + )} + + + +
+ +
+ ); +} \ No newline at end of file diff --git a/omsorgapp/src/components/OmsorgCard.jsx b/omsorgapp/src/components/OmsorgCard.jsx new file mode 100644 index 0000000..24cf0e0 --- /dev/null +++ b/omsorgapp/src/components/OmsorgCard.jsx @@ -0,0 +1,38 @@ +/** + * ========================================================== + * OMSORG BUSINESS CONTROLS PRO + * Component: OmsorgCard + * Sprint: 0.2.0 + * ========================================================== + */ + +export default function OmsorgCard({ + title, + value, + subtitle, + children, +}) { + return ( +
+ {title && ( +
+

{title}

+
+ )} + + {value && ( +
+ {value} +
+ )} + + {subtitle && ( +

+ {subtitle} +

+ )} + + {children} +
+ ); +} \ No newline at end of file diff --git a/omsorgapp/src/components/Sidebar.jsx b/omsorgapp/src/components/Sidebar.jsx new file mode 100644 index 0000000..0b16e5f --- /dev/null +++ b/omsorgapp/src/components/Sidebar.jsx @@ -0,0 +1,103 @@ +import logo from "../assets/omsorg_logo.png"; +import { + Home, + Users, + Building2, + CalendarDays, + Calculator, + Car, + FileText, + BarChart3, + Settings, + Bug +} from "lucide-react"; +import { useAuth } from "../app/AuthContext"; +import { isNavItemVisible } from "../app/navPermissions"; + +const menu = [ + { icon: Home, label: "Home" }, + { icon: Users, label: "Mitarbeiter" }, + { icon: Building2, label: "Kunden" }, + { icon: CalendarDays, label: "Disposition" }, + { icon: Calculator, label: "Kalkulation" }, + { icon: Car, label: "Fahrzeuge" }, + { icon: FileText, label: "Rechnungen" }, + { icon: BarChart3, label: "Controlling" }, + { icon: Settings, label: "Einstellungen" }, + { icon: Bug, label: "Debug" } +]; + +export default function Sidebar({ + activePage, + onNavigate +}) { + const { hasPermission } = useAuth(); + const items = menu.filter((item) => isNavItemVisible(item.label, hasPermission)); + + return ( + + + + ); + +} \ No newline at end of file diff --git a/omsorgapp/src/components/ui/OmsorgBadge.jsx b/omsorgapp/src/components/ui/OmsorgBadge.jsx new file mode 100644 index 0000000..93103e5 --- /dev/null +++ b/omsorgapp/src/components/ui/OmsorgBadge.jsx @@ -0,0 +1,58 @@ +import { + CheckCircle2, + BriefcaseMedical, + Palmtree, + HeartPulse, + Clock3, +} from "lucide-react"; + +const badgeConfig = { + aktiv: { + label: "Aktiv", + icon: CheckCircle2, + className: "success", + }, + + einsatz: { + label: "Einsatz", + icon: BriefcaseMedical, + className: "info", + }, + + urlaub: { + label: "Urlaub", + icon: Palmtree, + className: "warning", + }, + + krank: { + label: "Krank", + icon: HeartPulse, + className: "danger", + }, + + vertrag: { + label: "Läuft aus", + icon: Clock3, + className: "neutral", + }, +}; + +export default function OmsorgBadge({ + status = "aktiv", +}) { + + const config = + badgeConfig[status] ?? badgeConfig.aktiv; + + const Icon = config.icon; + + return ( + + + {config.label} + + ); +} \ No newline at end of file diff --git a/omsorgapp/src/components/ui/OmsorgButton.jsx b/omsorgapp/src/components/ui/OmsorgButton.jsx new file mode 100644 index 0000000..e0e4286 --- /dev/null +++ b/omsorgapp/src/components/ui/OmsorgButton.jsx @@ -0,0 +1,42 @@ +/** + * ========================================================== + * OMSORG BUSINESS CONTROLS PRO + * Component: OmsorgButton + * Sprint: 0.2.0 + * ========================================================== + */ + +export default function OmsorgButton({ + children, + variant = "primary", + icon: Icon, + type = "button", + disabled = false, + onClick, +}) { + + const className = + `omsorg-button omsorg-button--${variant}`; + + return ( + + + + ); + +} \ No newline at end of file diff --git a/omsorgapp/src/components/ui/OmsorgPagination.jsx b/omsorgapp/src/components/ui/OmsorgPagination.jsx new file mode 100644 index 0000000..de545f6 --- /dev/null +++ b/omsorgapp/src/components/ui/OmsorgPagination.jsx @@ -0,0 +1,31 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +import OmsorgButton from "./OmsorgButton"; + +export default function OmsorgPagination({ page, totalPages, onPageChange }) { + return ( +
+ onPageChange(page - 1)} + > + Zurück + + + + Seite {page} von {totalPages} + + + = totalPages} + onClick={() => onPageChange(page + 1)} + > + Weiter + +
+ ); +} diff --git a/omsorgapp/src/components/ui/OmsorgStatCard.jsx b/omsorgapp/src/components/ui/OmsorgStatCard.jsx new file mode 100644 index 0000000..b0cfd1e --- /dev/null +++ b/omsorgapp/src/components/ui/OmsorgStatCard.jsx @@ -0,0 +1,51 @@ +import { + ArrowDownRight, + ArrowUpRight, + Minus, +} from "lucide-react"; + +export default function OmsorgStatCard({ + title, + value, + subtitle, + trend = "neutral", + icon: Icon, +}) { + const TrendIcon = + trend === "up" + ? ArrowUpRight + : trend === "down" + ? ArrowDownRight + : Minus; + + return ( +
+
+
+

+ {title} +

+ + + {value} + +
+ + {Icon && ( +
+ +
+ )} +
+ + {subtitle && ( +
+ + {subtitle} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/omsorgapp/src/layouts/AppLayout.jsx b/omsorgapp/src/layouts/AppLayout.jsx new file mode 100644 index 0000000..7da642c --- /dev/null +++ b/omsorgapp/src/layouts/AppLayout.jsx @@ -0,0 +1,31 @@ +import Sidebar from "../components/Sidebar"; +import Header from "../components/Header"; + +export default function AppLayout({ + children, + activePage, + onNavigate, +}) { + return ( +
+ + + +
+ +
+ +
+ + {children} + +
+ +
+ +
+ ); +} \ No newline at end of file diff --git a/omsorgapp/src/main.jsx b/omsorgapp/src/main.jsx new file mode 100644 index 0000000..791397e --- /dev/null +++ b/omsorgapp/src/main.jsx @@ -0,0 +1,13 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./app/app"; +import { AuthProvider } from "./app/AuthContext"; +import "./style.css"; + +createRoot(document.getElementById("root")).render( + + + + + +); \ No newline at end of file diff --git a/omsorgapp/src/main.legacy.jsx b/omsorgapp/src/main.legacy.jsx new file mode 100644 index 0000000..d977e25 --- /dev/null +++ b/omsorgapp/src/main.legacy.jsx @@ -0,0 +1,56 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Bell, BriefcaseBusiness, Calculator, CalendarClock, Database, FileText, Home, Mail, Phone, Plus, Save, Settings, Shield, Users } from 'lucide-react'; +import logo from './assets/omsorg_logo.png'; +import './style.css'; + +const nav = [ + ['home', Home, 'Home'], ['employees', Users, 'Mitarbeiter'], ['customers', BriefcaseBusiness, 'Kunden'], + ['dispo', CalendarClock, 'Disposition'], ['calc', Calculator, 'Kalkulation'], ['docs', FileText, 'Dokumente'], ['settings', Settings, 'Einstellungen'] +]; +const genId = (prefix) => `${prefix}-${Math.random().toString(36).slice(2, 9)}`; + +function App(){ + const [active,setActive]=useState('home'); + const [data,setData]=useState(null); + const [paths,setPaths]=useState(null); + const [selected,setSelected]=useState(null); + const [toast,setToast]=useState(''); + useEffect(()=>{ (async()=>{ setData(await window.omsorg.getDb()); setPaths(await window.omsorg.paths()); })(); },[]); + const save = async (next, msg='Gespeichert') => { setData(next); await window.omsorg.setDb(next); setToast(msg); setTimeout(()=>setToast(''),2200); }; + const warnings = useMemo(()=>{ + if(!data) return []; + const now = new Date(); + return data.assignments.filter(a=>a.endDate).map(a=>({...a, days: Math.ceil((new Date(a.endDate)-now)/86400000)})).filter(a=>a.days>=0 && a.days<=30); + },[data]); + if(!data) return
Omsorg Business Controls Pro startet...
; + return
+ +
+

{nav.find(n=>n[0]===active)?.[2]}

Project Aurora · Omsorg Dautovic & Neumann GbR

+ {active==='home' && } + {active==='employees' && } + {active==='customers' && } + {active==='dispo' && } + {active==='calc' && } + {active==='docs' && } + {active==='settings' && } +
+ {selected && setSelected(null)} data={data} save={save}/>} + {toast &&
{toast}
} +
+} +function HomeView({data,warnings,setActive,setSelected}){return

Guten Morgen, Omsorg

Die erste echte lokale Projektbasis läuft. Ohne native SQLite-Bindings, stabil auf dem MacBook.

{data.employees.length} Mitarbeiter{data.customers.length} Kunden{data.assignments.length} Einsätze

Heute zu erledigen

{warnings.length?warnings.map(w=>):

Keine Vertragswarnungen in den nächsten 30 Tagen.

}

Schnellstart

} +function Employees({data,save,setSelected}){const [form,setForm]=useState({name:'',qualification:'3-jährige Pflegefachkraft',status:'Aktiv'}); const add=()=>{if(!form.name.trim())return; save({...data,employees:[...data.employees,{id:genId('e'),...form}]}); setForm({...form,name:''});}; const del=(id)=>{if(confirm('Mitarbeiter wirklich löschen?')) save({...data,employees:data.employees.filter(e=>e.id!==id)});}; return
setForm({...form,name:e.target.value})}/>
{data.employees.map(e=>
setSelected({type:'employee',...e})}>
{e.name}{e.qualification} · {e.status}
)}
} +function Customers({data,save,setSelected}){const [f,setF]=useState({name:'',phone:'',email:'',contact:''}); const add=()=>{if(!f.name.trim())return; save({...data,customers:[...data.customers,{id:genId('c'),...f}]}); setF({name:'',phone:'',email:'',contact:''});}; return
setF({...f,name:e.target.value})}/>setF({...f,phone:e.target.value})}/>setF({...f,email:e.target.value})}/>
{data.customers.map(c=>
setSelected({type:'customer',...c})}>
{c.name}{c.phone || 'keine Telefonnummer'} · {c.email || 'keine E-Mail'}
)}
} +function Disposition({data,save,setSelected}){const [f,setF]=useState({customerName:'',employeeName:'',startDate:'',endDate:'',phone:'',email:''}); const add=()=>{if(!f.customerName.trim())return; save({...data,assignments:[...data.assignments,{id:genId('a'),...f,createdAt:new Date().toISOString()}]}); setF({customerName:'',employeeName:'',startDate:'',endDate:'',phone:'',email:''});}; return
setF({...f,customerName:e.target.value})}/>setF({...f,employeeName:e.target.value})}/>setF({...f,startDate:e.target.value})}/>setF({...f,endDate:e.target.value})}/>setF({...f,phone:e.target.value})}/>setF({...f,email:e.target.value})}/>
{data.assignments.map(a=>
setSelected({type:'assignment',...a})}>
{a.customerName}{a.employeeName || 'ohne Mitarbeiter'} · {a.startDate || '?'} bis {a.endDate || '?'}
)}
} +function Calc(){const [rate,setRate]=useState(48.95),[hours,setHours]=useState(36.5),[margin,setMargin]=useState(25); const revenue=rate*hours; const targetCost=revenue*(1-margin/100); return

Wochenkalkulation

Ergebnis

{revenue.toLocaleString('de-DE',{style:'currency',currency:'EUR'})}

Maximale Kosten bei Zielmarge: {targetCost.toLocaleString('de-DE',{style:'currency',currency:'EUR'})}

} +function Docs({paths}){return

Dokumentenspeicher

Release 0.1.1 legt die Ordnerstruktur lokal im Benutzerordner an.

{paths?.root}
} +function SettingsView({paths}){return

Systempfade

Datenbank:

{paths?.db}

Projektordner:

{paths?.root}
} +function Modal({item,onClose,data,save}){ const prolong=(id)=>{const newDate=prompt('Neues Vertragsende YYYY-MM-DD'); if(!newDate)return; const next={...data,assignments:data.assignments.map(a=>a.id===id?{...a,endDate:newDate}:a)}; save(next,'Vertrag verlängert'); onClose();}; return

{item.name || item.customerName}

{item.type==='assignment'&&<>

Mitarbeiter: {item.employeeName || '-'}

Vertrag: {item.startDate || '?'} bis {item.endDate || '?'}

{item.phone || 'keine Nummer'}

{item.email || 'keine E-Mail'}

E-Mail senden
}{item.type==='employee'&&

{item.qualification} · {item.status}

}{item.type==='customer'&&<>

{item.phone}

{item.email}

}
} + +createRoot(document.getElementById('root')).render(); diff --git a/omsorgapp/src/modules/auth/ChangePasswordScreen.jsx b/omsorgapp/src/modules/auth/ChangePasswordScreen.jsx new file mode 100644 index 0000000..0be66fb --- /dev/null +++ b/omsorgapp/src/modules/auth/ChangePasswordScreen.jsx @@ -0,0 +1,93 @@ +import { useState } from "react"; +import { KeyRound } from "lucide-react"; +import OmsorgCard from "../../components/OmsorgCard"; +import OmsorgButton from "../../components/ui/OmsorgButton"; +import { useAuth } from "../../app/AuthContext"; + +export default function ChangePasswordScreen() { + const { changePassword } = useAuth(); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event) { + event.preventDefault(); + setError(null); + + if (newPassword.length < 8) { + setError("Das neue Passwort muss mindestens 8 Zeichen lang sein."); + return; + } + + if (newPassword !== confirmPassword) { + setError("Die Passwörter stimmen nicht überein."); + return; + } + + setIsSubmitting(true); + const result = await changePassword(currentPassword, newPassword); + setIsSubmitting(false); + + if (!result.success) { + setError( + result.error === "invalid_current_password" + ? "Aktuelles Passwort ist falsch." + : "Passwort konnte nicht geändert werden." + ); + } + // Bei Erfolg beendet changePassword() die Session lokal - der Login-Screen erscheint automatisch. + } + + return ( +
+ +

Passwort ändern erforderlich

+

+ Für diesen Account wurde ein initiales Passwort vergeben. Bitte lege jetzt dein eigenes Passwort fest, + bevor du weiterarbeiten kannst. +

+ +
+ + + + + + + {error &&

{error}

} + + + {isSubmitting ? "Ändert..." : "Passwort ändern"} + +
+
+
+ ); +} diff --git a/omsorgapp/src/modules/auth/ForgotPasswordNewPasswordPage.jsx b/omsorgapp/src/modules/auth/ForgotPasswordNewPasswordPage.jsx new file mode 100644 index 0000000..d856ef1 --- /dev/null +++ b/omsorgapp/src/modules/auth/ForgotPasswordNewPasswordPage.jsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { KeyRound } from "lucide-react"; +import OmsorgCard from "../../components/OmsorgCard"; +import OmsorgButton from "../../components/ui/OmsorgButton"; + +export default function ForgotPasswordNewPasswordPage({ resetToken, onDone, onBackToLogin }) { + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event) { + event.preventDefault(); + setError(null); + + if (password !== confirmPassword) { + setError("Die Passwörter stimmen nicht überein."); + return; + } + + setIsSubmitting(true); + const result = await window.omsorg.auth.resetPassword(resetToken, password); + setIsSubmitting(false); + + if (result.success) { + onDone(); + } else { + setError("Der Code ist abgelaufen. Bitte fordere einen neuen an."); + } + } + + return ( +
+ +

+ Neues + Passwort setzen +

+ +
+ + + + + {error &&

{error}

} + + + {isSubmitting ? "Wird gespeichert..." : "Passwort speichern"} + + +
+ +
+
+
+
+ ); +} diff --git a/omsorgapp/src/modules/auth/ForgotPasswordPinPage.jsx b/omsorgapp/src/modules/auth/ForgotPasswordPinPage.jsx new file mode 100644 index 0000000..5ff002a --- /dev/null +++ b/omsorgapp/src/modules/auth/ForgotPasswordPinPage.jsx @@ -0,0 +1,81 @@ +import { useState } from "react"; +import { ShieldCheck } from "lucide-react"; +import OmsorgCard from "../../components/OmsorgCard"; +import OmsorgButton from "../../components/ui/OmsorgButton"; + +export default function ForgotPasswordPinPage({ username, onVerified, onRestart, onBackToLogin }) { + const [pin, setPin] = useState(""); + const [error, setError] = useState(null); + const [tooManyAttempts, setTooManyAttempts] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event) { + event.preventDefault(); + setError(null); + setTooManyAttempts(false); + setIsSubmitting(true); + const result = await window.omsorg.auth.verifyPasswordResetCode(username, pin); + setIsSubmitting(false); + + if (result.success) { + onVerified(result.resetToken); + return; + } + + if (result.error === "too_many_attempts") { + setTooManyAttempts(true); + } else { + setError("Code falsch oder abgelaufen."); + } + } + + return ( +
+ +

+ Code + eingeben +

+

+ Wir haben einen 6-stelligen Code an die hinterlegte E-Mail-Adresse gesendet. Der Code ist 5 Minuten gültig. +

+ +
+ + + {error &&

{error}

} + {tooManyAttempts && ( +

+ Zu viele Fehlversuche. Bitte fordere einen neuen Code an. +

+ )} + + + {isSubmitting ? "Wird geprüft..." : "Bestätigen"} + + +
+ + +
+
+
+
+ ); +} diff --git a/omsorgapp/src/modules/auth/ForgotPasswordUsernamePage.jsx b/omsorgapp/src/modules/auth/ForgotPasswordUsernamePage.jsx new file mode 100644 index 0000000..eea211f --- /dev/null +++ b/omsorgapp/src/modules/auth/ForgotPasswordUsernamePage.jsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { Mail } from "lucide-react"; +import OmsorgCard from "../../components/OmsorgCard"; +import OmsorgButton from "../../components/ui/OmsorgButton"; + +export default function ForgotPasswordUsernamePage({ onNext, onBackToLogin }) { + const [username, setUsername] = useState(""); + const [error, setError] = useState(null); + const [cannotReset, setCannotReset] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event) { + event.preventDefault(); + setError(null); + setCannotReset(false); + setIsSubmitting(true); + const result = await window.omsorg.auth.requestPasswordReset(username); + setIsSubmitting(false); + + if (!result.success) { + setError("Server nicht erreichbar. Bitte Verbindung prüfen und erneut versuchen."); + return; + } + + if (result.status === "sent") { + onNext(username); + } else { + setCannotReset(true); + } + } + + return ( +
+ +

+ Passwort + vergessen +

+ +
+ + + {error &&

{error}

} + {cannotReset && ( +

+ Für diesen Account ist kein Passwort-Reset per E-Mail möglich. Bitte wende dich an einen Administrator. +

+ )} + + + {isSubmitting ? "Wird gesendet..." : "Code anfordern"} + + +
+ +
+
+
+
+ ); +} diff --git a/omsorgapp/src/modules/auth/LoginPage.jsx b/omsorgapp/src/modules/auth/LoginPage.jsx new file mode 100644 index 0000000..16e58f1 --- /dev/null +++ b/omsorgapp/src/modules/auth/LoginPage.jsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { LogIn } from "lucide-react"; +import OmsorgCard from "../../components/OmsorgCard"; +import OmsorgButton from "../../components/ui/OmsorgButton"; +import { useAuth } from "../../app/AuthContext"; + +export default function LoginPage({ onForgotPassword }) { + const { login } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event) { + event.preventDefault(); + setError(null); + setIsSubmitting(true); + const result = await login(username, password); + setIsSubmitting(false); + if (!result.success) { + let message = "Benutzername oder Passwort falsch."; + if (result.error === "network_error") { + message = "Server nicht erreichbar. Bitte Verbindung prüfen und erneut versuchen."; + } else if (result.error === "too_many_attempts") { + message = "Zu viele Fehlversuche. Bitte in einigen Minuten erneut versuchen."; + } + setError(message); + } + } + + return ( +
+ +

+ Anmelden bei + Omsorg Business Controls Pro +

+ +
+ + + + + {error &&

{error}

} + + + {isSubmitting ? "Anmelden..." : "Anmelden"} + + +
+ +
+
+
+
+ ); +} diff --git a/omsorgapp/src/modules/debug/DebugSessionsPage.jsx b/omsorgapp/src/modules/debug/DebugSessionsPage.jsx new file mode 100644 index 0000000..ac6faf3 --- /dev/null +++ b/omsorgapp/src/modules/debug/DebugSessionsPage.jsx @@ -0,0 +1,180 @@ +import { useCallback, useEffect, useState } from "react"; +import { LogOut, Mail, RotateCw, ShieldAlert } from "lucide-react"; +import OmsorgCard from "../../components/OmsorgCard"; +import OmsorgButton from "../../components/ui/OmsorgButton"; +import { useAuth } from "../../app/AuthContext"; + +function formatDate(iso) { + return new Date(iso).toLocaleString("de-DE"); +} + +export default function DebugSessionsPage() { + const { logout } = useAuth(); + const [sessions, setSessions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const [testEmailAddress, setTestEmailAddress] = useState(""); + const [testEmailSubject, setTestEmailSubject] = useState(""); + const [testEmailBody, setTestEmailBody] = useState(""); + const [isSendingTestEmail, setIsSendingTestEmail] = useState(false); + const [testEmailResult, setTestEmailResult] = useState(null); + + const loadSessions = useCallback(async () => { + setIsLoading(true); + setError(null); + const result = await window.omsorg.api.get("/api/admin/sessions"); + if (result.ok) { + setSessions(result.data ?? []); + } else { + setError("Sessions konnten nicht geladen werden."); + } + setIsLoading(false); + }, []); + + useEffect(() => { + loadSessions(); + }, [loadSessions]); + + useEffect(() => { + async function loadTemplate() { + const result = await window.omsorg.api.get("/api/admin/email/password-reset-template"); + if (result.ok) { + setTestEmailSubject(result.data.subject); + setTestEmailBody(result.data.bodyTemplate); + } + } + loadTemplate(); + }, []); + + async function revokeSession(id) { + await window.omsorg.api.post(`/api/admin/sessions/${id}/revoke`); + await loadSessions(); + } + + async function revokeAll() { + await window.omsorg.api.post("/api/admin/sessions/revoke-all"); + await logout(); + } + + async function sendTestEmail() { + setIsSendingTestEmail(true); + setTestEmailResult(null); + const result = await window.omsorg.api.post("/api/admin/email/test-send", { + toAddress: testEmailAddress, + subject: testEmailSubject, + body: testEmailBody, + }); + setTestEmailResult( + result.ok + ? { ok: true, message: `Testmail an ${testEmailAddress} ausgelöst.` } + : { ok: false, message: "Testmail konnte nicht versendet werden." } + ); + setIsSendingTestEmail(false); + } + + return ( + +

+ Erzwingt bei einzelnen oder allen Nutzern sofort einen neuen Login - auch + bereits ausgestellte Access-Tokens werden dabei ungültig, nicht erst nach Ablauf. +

+ +
+ + Aktualisieren + + + Alle abmelden + +
+ + {isLoading &&

Lädt...

} + {error &&

{error}

} + + {!isLoading && !error && sessions.length === 0 && ( +

Keine aktiven Sessions.

+ )} + + {sessions.length > 0 && ( +
    + {sessions.map((session) => ( +
  • +
    + {session.username} +

    + Ausgestellt: {formatDate(session.createdAt)} · Läuft ab: {formatDate(session.expiresAt)} +

    +
    + revokeSession(session.id)} + > + Abmelden + +
  • + ))} +
+ )} + +
+ +

E-Mail-Versand testen

+

+ Sendet eine Passwort-Reset-Testmail über den serverseitig konfigurierten Mail-Versand + (Email:Provider, Account Email:PasswordResetAccount). Betreff/Text + sind mit der echten Vorlage vorbefüllt und können vor dem Senden angepasst werden. Zugangsdaten + selbst werden dabei nie an den Client übertragen. +

+ +
+ + setTestEmailAddress(event.target.value)} + /> +
+ +
+ + setTestEmailSubject(event.target.value)} + /> +
+ +
+ +