From e2085f2dbec9461f3d1319f018e7916f6a925010 Mon Sep 17 00:00:00 2001 From: bertinaabadmin Date: Thu, 30 Jul 2026 11:43:44 +0200 Subject: [PATCH] Initial commit --- .gitignore | 31 ++ README.md | 222 ++++++++++++++ Rinta.sln | 33 ++ push-to-github.ps1 | 28 ++ .../Controllers/AppointmentsController.cs | 240 +++++++++++++++ src/Rinta.Api/Controllers/AuthController.cs | 73 +++++ .../Controllers/ClinicalNotesController.cs | 133 ++++++++ .../Controllers/MedicationsController.cs | 99 ++++++ .../Controllers/PatientsController.cs | 179 +++++++++++ .../Controllers/PrescriptionsController.cs | 164 ++++++++++ src/Rinta.Api/Controllers/UsersController.cs | 96 ++++++ src/Rinta.Api/DTOs/AppointmentDtos.cs | 39 +++ src/Rinta.Api/DTOs/AuthDtos.cs | 26 ++ src/Rinta.Api/DTOs/ClinicalNoteDtos.cs | 35 +++ src/Rinta.Api/DTOs/PatientDtos.cs | 52 ++++ src/Rinta.Api/DTOs/PrescriptionDtos.cs | 55 ++++ src/Rinta.Api/Program.cs | 160 ++++++++++ src/Rinta.Api/Properties/launchSettings.json | 41 +++ src/Rinta.Api/Rinta.Api.csproj | 19 ++ src/Rinta.Api/appsettings.json | 24 ++ src/Rinta.Desktop/App.xaml | 145 +++++++++ src/Rinta.Desktop/App.xaml.cs | 45 +++ src/Rinta.Desktop/Assets/rinta-icon.ico | Bin 0 -> 21025 bytes src/Rinta.Desktop/Assets/rinta-logo.png | Bin 0 -> 40206 bytes src/Rinta.Desktop/Assets/rinta-logo.svg | 97 ++++++ src/Rinta.Desktop/Models/ApiModels.cs | 132 ++++++++ src/Rinta.Desktop/Rinta.Desktop.csproj | 19 ++ src/Rinta.Desktop/Services/ApiClient.cs | 286 ++++++++++++++++++ .../Windows/AdminUsersWindow.xaml | 79 +++++ .../Windows/AdminUsersWindow.xaml.cs | 120 ++++++++ .../Windows/CorrectNoteDialog.xaml | 29 ++ .../Windows/CorrectNoteDialog.xaml.cs | 31 ++ src/Rinta.Desktop/Windows/LoginWindow.xaml | 32 ++ src/Rinta.Desktop/Windows/LoginWindow.xaml.cs | 40 +++ src/Rinta.Desktop/Windows/MainWindow.xaml | 40 +++ src/Rinta.Desktop/Windows/MainWindow.xaml.cs | 82 +++++ .../Windows/PatientJournalWindow.xaml | 159 ++++++++++ .../Windows/PatientJournalWindow.xaml.cs | 280 +++++++++++++++++ .../Windows/PatientRegisterWindow.xaml | 59 ++++ .../Windows/PatientRegisterWindow.xaml.cs | 67 ++++ .../Windows/ReplacePrescriptionDialog.xaml | 39 +++ .../Windows/ReplacePrescriptionDialog.xaml.cs | 33 ++ .../Windows/TextInputDialog.xaml | 20 ++ .../Windows/TextInputDialog.xaml.cs | 32 ++ src/Rinta.Desktop/appsettings.json | 3 + .../Common/PersonnummerValidator.cs | 82 +++++ .../Common/ReservationNumberGenerator.cs | 27 ++ src/Rinta.Domain/Common/TenantEntityBase.cs | 42 +++ src/Rinta.Domain/Entities/Appointment.cs | 42 +++ src/Rinta.Domain/Entities/AuditLogEntry.cs | 43 +++ src/Rinta.Domain/Entities/ClinicalNote.cs | 52 ++++ .../Entities/ClinicianWorkingHours.cs | 18 ++ .../Entities/MedicationCatalogEntry.cs | 25 ++ src/Rinta.Domain/Entities/Patient.cs | 49 +++ src/Rinta.Domain/Entities/Prescription.cs | 56 ++++ src/Rinta.Domain/Entities/Tenant.cs | 17 ++ src/Rinta.Domain/Rinta.Domain.csproj | 7 + .../Data/AuditingSaveChangesInterceptor.cs | 114 +++++++ .../Data/JournalDbContext.cs | 192 ++++++++++++ .../ExternalApis/IMedicationLookupService.cs | 21 ++ .../ExternalApis/INavetLookupService.cs | 25 ++ .../ExternalApis/NavetLookupService.cs | 72 +++++ .../OpenFdaMedicationLookupService.cs | 101 +++++++ .../Identity/ApplicationUser.cs | 23 ++ .../Identity/TenantClaimsPrincipalFactory.cs | 31 ++ .../Rinta.Infrastructure.csproj | 17 ++ .../Security/HmacIdentifierHasher.cs | 38 +++ .../Security/HttpTenantProvider.cs | 44 +++ .../Services/PatientService.cs | 101 +++++++ start-all.ps1 | 12 + 70 files changed, 4869 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Rinta.sln create mode 100644 push-to-github.ps1 create mode 100644 src/Rinta.Api/Controllers/AppointmentsController.cs create mode 100644 src/Rinta.Api/Controllers/AuthController.cs create mode 100644 src/Rinta.Api/Controllers/ClinicalNotesController.cs create mode 100644 src/Rinta.Api/Controllers/MedicationsController.cs create mode 100644 src/Rinta.Api/Controllers/PatientsController.cs create mode 100644 src/Rinta.Api/Controllers/PrescriptionsController.cs create mode 100644 src/Rinta.Api/Controllers/UsersController.cs create mode 100644 src/Rinta.Api/DTOs/AppointmentDtos.cs create mode 100644 src/Rinta.Api/DTOs/AuthDtos.cs create mode 100644 src/Rinta.Api/DTOs/ClinicalNoteDtos.cs create mode 100644 src/Rinta.Api/DTOs/PatientDtos.cs create mode 100644 src/Rinta.Api/DTOs/PrescriptionDtos.cs create mode 100644 src/Rinta.Api/Program.cs create mode 100644 src/Rinta.Api/Properties/launchSettings.json create mode 100644 src/Rinta.Api/Rinta.Api.csproj create mode 100644 src/Rinta.Api/appsettings.json create mode 100644 src/Rinta.Desktop/App.xaml create mode 100644 src/Rinta.Desktop/App.xaml.cs create mode 100644 src/Rinta.Desktop/Assets/rinta-icon.ico create mode 100644 src/Rinta.Desktop/Assets/rinta-logo.png create mode 100644 src/Rinta.Desktop/Assets/rinta-logo.svg create mode 100644 src/Rinta.Desktop/Models/ApiModels.cs create mode 100644 src/Rinta.Desktop/Rinta.Desktop.csproj create mode 100644 src/Rinta.Desktop/Services/ApiClient.cs create mode 100644 src/Rinta.Desktop/Windows/AdminUsersWindow.xaml create mode 100644 src/Rinta.Desktop/Windows/AdminUsersWindow.xaml.cs create mode 100644 src/Rinta.Desktop/Windows/CorrectNoteDialog.xaml create mode 100644 src/Rinta.Desktop/Windows/CorrectNoteDialog.xaml.cs create mode 100644 src/Rinta.Desktop/Windows/LoginWindow.xaml create mode 100644 src/Rinta.Desktop/Windows/LoginWindow.xaml.cs create mode 100644 src/Rinta.Desktop/Windows/MainWindow.xaml create mode 100644 src/Rinta.Desktop/Windows/MainWindow.xaml.cs create mode 100644 src/Rinta.Desktop/Windows/PatientJournalWindow.xaml create mode 100644 src/Rinta.Desktop/Windows/PatientJournalWindow.xaml.cs create mode 100644 src/Rinta.Desktop/Windows/PatientRegisterWindow.xaml create mode 100644 src/Rinta.Desktop/Windows/PatientRegisterWindow.xaml.cs create mode 100644 src/Rinta.Desktop/Windows/ReplacePrescriptionDialog.xaml create mode 100644 src/Rinta.Desktop/Windows/ReplacePrescriptionDialog.xaml.cs create mode 100644 src/Rinta.Desktop/Windows/TextInputDialog.xaml create mode 100644 src/Rinta.Desktop/Windows/TextInputDialog.xaml.cs create mode 100644 src/Rinta.Desktop/appsettings.json create mode 100644 src/Rinta.Domain/Common/PersonnummerValidator.cs create mode 100644 src/Rinta.Domain/Common/ReservationNumberGenerator.cs create mode 100644 src/Rinta.Domain/Common/TenantEntityBase.cs create mode 100644 src/Rinta.Domain/Entities/Appointment.cs create mode 100644 src/Rinta.Domain/Entities/AuditLogEntry.cs create mode 100644 src/Rinta.Domain/Entities/ClinicalNote.cs create mode 100644 src/Rinta.Domain/Entities/ClinicianWorkingHours.cs create mode 100644 src/Rinta.Domain/Entities/MedicationCatalogEntry.cs create mode 100644 src/Rinta.Domain/Entities/Patient.cs create mode 100644 src/Rinta.Domain/Entities/Prescription.cs create mode 100644 src/Rinta.Domain/Entities/Tenant.cs create mode 100644 src/Rinta.Domain/Rinta.Domain.csproj create mode 100644 src/Rinta.Infrastructure/Data/AuditingSaveChangesInterceptor.cs create mode 100644 src/Rinta.Infrastructure/Data/JournalDbContext.cs create mode 100644 src/Rinta.Infrastructure/ExternalApis/IMedicationLookupService.cs create mode 100644 src/Rinta.Infrastructure/ExternalApis/INavetLookupService.cs create mode 100644 src/Rinta.Infrastructure/ExternalApis/NavetLookupService.cs create mode 100644 src/Rinta.Infrastructure/ExternalApis/OpenFdaMedicationLookupService.cs create mode 100644 src/Rinta.Infrastructure/Identity/ApplicationUser.cs create mode 100644 src/Rinta.Infrastructure/Identity/TenantClaimsPrincipalFactory.cs create mode 100644 src/Rinta.Infrastructure/Rinta.Infrastructure.csproj create mode 100644 src/Rinta.Infrastructure/Security/HmacIdentifierHasher.cs create mode 100644 src/Rinta.Infrastructure/Security/HttpTenantProvider.cs create mode 100644 src/Rinta.Infrastructure/Services/PatientService.cs create mode 100644 start-all.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..606e082 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Byggartefakter +bin/ +obj/ +out/ + +# Visual Studio +.vs/ +*.user +*.suo + +# Rider / VS Code +.idea/ +.vscode/ + +# Publish-profiler +**/Properties/PublishProfiles/*.pubxml +**/Properties/PublishProfiles/*.pubxml.user + +# Loggar +*.log + +# OBS: appsettings.json committas MEDVETET - den innehåller bara +# platshållarvärden (t.ex. "REPLACE_WITH_BASE64_ENCODED_256BIT_KEY"), inga +# riktiga hemligheter. Riktiga nycklar/lösenord ska ALDRIG hamna här - de +# hör hemma i `dotnet user-secrets` (lokalt, utanför repot) eller en +# hemlighetshanterare i produktion. Dubbelkolla innan varje commit att +# ingen råkat klistra in en riktig connection string eller nyckel här. + +# Databasfiler om ni någon gång kör lokalt mot SQLite för test +*.db +*.sqlite diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab1d23c --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +

+ Rinta +

+ +# Rinta + +Multi-tenant journalsystem för svensk vård, byggt i C#/.NET 8. Uppdelat i +ett rent JSON-API (servern) och ett fristående skrivbordsprogram (WPF, +Windows) som klinikpersonal faktiskt loggar in i — inget webb-UI. + +**Status:** aktiv scaffold under utveckling, inte en färdig eller +certifierad produkt. Se [Vad som saknas](#vad-som-saknas-roadmap) innan +detta går nära riktig patientdata. + +--- + +## Arkitektur + +``` +Rinta/ +├── src/ +│ ├── Rinta.Domain/ Entiteter, valideringslogik (Luhn, reservnummer). Inga beroenden utåt. +│ ├── Rinta.Infrastructure/ EF Core (MariaDB), ASP.NET Core Identity, kryptering, audit-logg, externa API:er +│ ├── Rinta.Api/ JSON-API. Inget eget UI. +│ └── Rinta.Desktop/ WPF-klient (Windows) - det program användarna faktiskt kör +└── Rinta.sln +``` + +Servern och klienten är separata processer som pratar HTTP med varandra. +De kan köras på samma dator (utveckling) eller på olika datorer i samma +nätverk. + +## Funktioner + +| Modul | Vad den gör | +|---|---| +| **Patienter** | Registrering med Luhn-validerat personnummer/samordningsnummer, eller automatiskt **reservnummer** om identiteten är okänd (medvetslös patient, nyfödd). Ingen åtkomst till SPAR/folkbokföringen krävs. | +| **Journalanteckningar** | Append-only enligt Patientdatalagen 3 kap 8§ - en anteckning ändras aldrig, bara rättas via en ny, länkad anteckning. Signering krävs. | +| **Recept/ordination** | Samma append-only-princip. Dosändring = sätt ut + skapa ny länkad ordination. Läkemedelssök mot openFDA (se begränsningar nedan). | +| **Tidbokning** | Bokning med dubbelbokningskontroll, ombokning, avbokning, lediga tider baserat på klinikerns arbetstider. | +| **Användare & roller** | Lokala konton (ingen SSO) - `Admin`/`Clinician`/`Receptionist`. En Admin skapar konton, ingen självregistrering. | +| **Audit-logg** | Alla läsningar och skrivningar av patientdata loggas automatiskt, inklusive break-glass-åtkomst. | + +## Vad Rinta medvetet INTE gör än + +- **Ingen SSO** - inloggning sker mot en lokal användardatabas + (ASP.NET Core Identity), inte Keycloak/Authentik/BankID/SITHS. Se + [Autentisering](#autentisering). +- **Ingen automatisk koppling mot folkbokföringen** - se + [Folkbokföring (Navet)](#folkbokföring-navet). +- **Läkemedelssök är inte ett svenskt beslutsstöd** - se + [Läkemedelssök](#läkemedelssök-openfda). + +--- + +## Autentisering + +Lokala konton via ASP.NET Core Identity, cookie-baserad session: + +- Ingen extern identitetsleverantör, ingen självregistrering. +- En `Admin` skapar nya konton (i klienten: **Hantera användare**), alltid + inom sin egen tenant (vårdgivare). +- Roller: `Admin`, `Clinician` (journal + ordination), `Receptionist` + (bokning + patientregistrering, inte journal). +- Lösenordskrav: minst 12 tecken, versal+gemen+siffra+specialtecken. + Konto låses 15 minuter efter 5 felförsök. +- `tenant_id` sätts ENDAST server-side vid inloggning, från kontots + lagrade `TenantId` - aldrig från klienten. + +**Första admin-kontot:** i Development-läge skapas automatiskt en +testklinik + ett `admin`-konto vid uppstart om databasen är tom +(`SeedDevelopmentDataAsync` i `Program.cs`). Lösenordet skrivs ut i +serverloggen - byt det direkt. I produktion ska detta INTE ske +automatiskt (se roadmap - ett CLI-verktyg för engångsprovisionering +saknas ännu). + +## Folkbokföring (Navet) + +Navet (Skatteverkets folkbokföringssystem) är **inte** ett fritt +tillgängligt API. Det riktar sig till myndigheter, kommuner och regioner +och kräver en godkänd ansökan hos Skatteverket samt ett klientcertifikat +(mTLS) - ingen enkel API-nyckel, och ingen testmiljö man bara kan koppla +upp sig mot. En integrationspunkt finns förberedd +(`Rinta.Infrastructure/ExternalApis/INavetLookupService.cs` / +`NavetLookupService.cs`) men är inte funktionell förrän ni har godkänd +åtkomst - den kastar ett tydligt fel istället för att låtsas fungera. + +## Läkemedelssök (openFDA) + +`/api/medications/search-by-diagnosis` och `/search-by-name` slår upp mot +**openFDA** (`api.fda.gov`, gratis, ingen nyckel krävs). Detta är +FDA-godkända amerikanska preparat med engelsk indikationstext - en +teknisk referenskälla, **inte** ett svenskt kliniskt beslutsstöd. Verklig +förskrivning bör stämmas av mot FASS, Läkemedelsverkets +behandlingsrekommendationer eller ett regionalt beslutsstöd (t.ex. +Janusinfo). `IMedicationLookupService` är byggt som en abstraktion +specifikt för att kunna bytas ut mot en riktig svensk källa (t.ex. NLL) +den dagen ni har avtal med E-hälsomyndigheten. + +--- + +## Kom igång + +### Krav + +- .NET 8 SDK +- MariaDB eller MySQL (lokalt eller på en server ni har åtkomst till) +- Windows för att köra `Rinta.Desktop` (WPF) +- `dotnet-ef`: `dotnet tool install --global dotnet-ef` + +### 1. Databas + +```sql +CREATE DATABASE rinta CHARACTER SET utf8mb4; +CREATE USER 'rinta_app'@'%' IDENTIFIED BY 'ett-bra-lösenord'; +GRANT ALL PRIVILEGES ON rinta.* TO 'rinta_app'@'%'; +FLUSH PRIVILEGES; +``` + +### 2. Servern (Rinta.Api) + +```bash +cd Rinta +dotnet restore +dotnet user-secrets init --project src/Rinta.Api +dotnet user-secrets set "Encryption:ColumnKey" "$(openssl rand -base64 32)" --project src/Rinta.Api +dotnet user-secrets set "Encryption:HashKey" "$(openssl rand -base64 32)" --project src/Rinta.Api +dotnet user-secrets set "ConnectionStrings:JournalDb" "Server=localhost;Port=3306;Database=rinta;User=rinta_app;Password=ett-bra-lösenord" --project src/Rinta.Api + +dotnet ef migrations add InitialCreate --project src/Rinta.Infrastructure --startup-project src/Rinta.Api +dotnet ef database update --project src/Rinta.Infrastructure --startup-project src/Rinta.Api + +dotnet dev-certs https --trust # första gången ni kör HTTPS lokalt + +dotnet run --project src/Rinta.Api --launch-profile http +``` +Kolla loggen för den utskrivna admin-lösenordet och vilken URL servern kör på. + +> **PowerShell utan `openssl`?** Använd istället: +> ```powershell +> $key = [Convert]::ToBase64String([byte[]](1..32 | ForEach-Object { Get-Random -Maximum 256 })) +> ``` + +### 3. Klienten (Rinta.Desktop) + +1. Sätt `ApiBaseUrl` i `src/Rinta.Desktop/appsettings.json` till samma + adress servern kör på. +2. Kör `Rinta.Desktop` (Windows/WPF) - F5 i Visual Studio, eller + `dotnet run --project src/Rinta.Desktop`. +3. Logga in med `admin` + lösenordet från serverloggen. + +### Allt i ett steg + +`start-all.ps1` i repo-roten startar servern, väntar, och startar sedan +klienten - kör `.\start-all.ps1`. + +### Nollställa databasen (utveckling) + +```sql +-- Anslut till 'sys'/'information_schema' eller en annan databas, inte 'rinta' själv +DROP DATABASE IF EXISTS rinta; +CREATE DATABASE rinta CHARACTER SET utf8mb4; +``` +Följt av `dotnet ef database update` igen (se steg 2). + +--- + +## Git & distribution (privat repo) + +```bash +git init +git add . +git commit -m "Initial commit" +git branch -M main +git remote add origin https://github.com/ANVANDARNAMN/rinta.git +git push -u origin main +``` +Eller kör `.\push-to-github.ps1` som gör allt ovan i ett steg (justera +`$RemoteUrl` i skriptet om ditt repo har ett annat namn). + +**Om hemligheter:** `.gitignore` utesluter `bin/`/`obj/`/`.vs/`. +`appsettings.json` committas MEDVETET - den innehåller bara +platshållarvärden, inga riktiga nycklar. De riktiga hemligheterna ligger i +`dotnet user-secrets`, som lagras utanför projektmappen och aldrig +committas automatiskt. Kolla ändå alltid `git status`/`git diff` innan en +commit. + +--- + +## Vad som saknas (roadmap) + +Ungefärlig prioritetsordning: + +1. **Behörighetsmodell på djupet** - patient-till-patient-behörighet + (t.ex. "endast tilldelat vårdteam"), samtyckeshantering, spärrmarkeringar + som faktiskt blockerar läsning. +2. **Tidbokning - undantag/frånvaro** - semester, sjukfrånvaro, + enskilda undantagsdagar (idag bara återkommande veckoschema). +3. **Läkemedel - riktig svensk källa** istället för/utöver openFDA + (kräver NLL-avtal), samt interaktionskontroll mellan aktiva ordinationer. +4. **HL7 FHIR-gränssnitt** (Firely .NET SDK) för interoperabilitet. +5. **Certifiering** - Socialstyrelsens föreskrifter, ev. Inera/ + regionkrav. Löses inte av kod ensamt. +6. **Separat, write-once-lagring för audit-loggen** (ligger idag i + samma databas som allt annat). +7. **Bortre radering/gallring** - patientens rätt att begära radering, + ett separat administrativt spår. +8. **Produktionssättning av kontohantering** - ett fristående + CLI-kommando för första tenant/admin (istället för dagens + Development-only auto-seed), samt tvingat lösenordsbyte vid första + inloggning. +9. **Tvåfaktorsautentisering** - Identity stödjer det, inte kopplat in än. +10. **Desktop-klienten täcker bara patienter, journal, recept och + användarhantering** - tidbokning har API-stöd men saknar egna fönster + i klienten ännu. +11. **Navet-integrationen är inte funktionell** - se ovan. + +--- + +## Licens / ägarskap + +Internt projekt. Inte licensierat för extern distribution. diff --git a/Rinta.sln b/Rinta.sln new file mode 100644 index 0000000..fa1ad6e --- /dev/null +++ b/Rinta.sln @@ -0,0 +1,33 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rinta.Domain", "src\Rinta.Domain\Rinta.Domain.csproj", "{11111111-1111-1111-1111-111111111111}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rinta.Infrastructure", "src\Rinta.Infrastructure\Rinta.Infrastructure.csproj", "{22222222-2222-2222-2222-222222222222}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rinta.Api", "src\Rinta.Api\Rinta.Api.csproj", "{33333333-3333-3333-3333-333333333333}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rinta.Desktop", "src\Rinta.Desktop\Rinta.Desktop.csproj", "{44444444-4444-4444-4444-444444444444}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11111111-1111-1111-1111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11111111-1111-1111-1111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU + {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU + {22222222-2222-2222-2222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU + {22222222-2222-2222-2222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU + {33333333-3333-3333-3333-333333333333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33333333-3333-3333-3333-333333333333}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33333333-3333-3333-3333-333333333333}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33333333-3333-3333-3333-333333333333}.Release|Any CPU.Build.0 = Release|Any CPU + {44444444-4444-4444-4444-444444444444}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44444444-4444-4444-4444-444444444444}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44444444-4444-4444-4444-444444444444}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44444444-4444-4444-4444-444444444444}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/push-to-github.ps1 b/push-to-github.ps1 new file mode 100644 index 0000000..02c8900 --- /dev/null +++ b/push-to-github.ps1 @@ -0,0 +1,28 @@ +param( + [string]$RemoteUrl = "https://github.com/bertinaabadmin/journalsystem.git", + [string]$CommitMessage = "Initial commit" +) + +Write-Host "Initierar Git-repo i $PSScriptRoot..." -ForegroundColor Cyan +Set-Location $PSScriptRoot + +if (-not (Test-Path ".git")) { + git init + git branch -M main +} else { + Write-Host "Git-repo finns redan här, hoppar över 'git init'." -ForegroundColor Yellow +} + +git add . +git commit -m "$CommitMessage" + +if (-not (git remote | Select-String "^origin$")) { + git remote add origin $RemoteUrl +} else { + Write-Host "Remote 'origin' finns redan, hoppar över 'git remote add'." -ForegroundColor Yellow +} + +Write-Host "Pushar till $RemoteUrl ..." -ForegroundColor Cyan +git push -u origin main + +Write-Host "Klart. Om ett inloggningsfönster dök upp - logga in med bertinaabadmin-kontot." -ForegroundColor Green diff --git a/src/Rinta.Api/Controllers/AppointmentsController.cs b/src/Rinta.Api/Controllers/AppointmentsController.cs new file mode 100644 index 0000000..cc7acf8 --- /dev/null +++ b/src/Rinta.Api/Controllers/AppointmentsController.cs @@ -0,0 +1,240 @@ +using Rinta.Api.DTOs; +using Rinta.Domain.Common; +using Rinta.Domain.Entities; +using Rinta.Infrastructure.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Rinta.Api.Controllers; + +[ApiController] +[Route("api/appointments")] +[Authorize] +public class AppointmentsController : ControllerBase +{ + private readonly JournalDbContext _db; + private readonly ITenantProvider _tenantProvider; + + public AppointmentsController(JournalDbContext db, ITenantProvider tenantProvider) + { + _db = db; + _tenantProvider = tenantProvider; + } + + [HttpPost] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task> Create(CreateAppointmentRequest request) + { + if (request.EndUtc <= request.StartUtc) + return BadRequest("Sluttid måste vara efter starttid."); + + var patientExists = await _db.Patients.AnyAsync(p => p.Id == request.PatientId); + if (!patientExists) return NotFound("Patient hittades inte."); + + if (await HasOverlapAsync(request.ClinicianUserId, request.StartUtc, request.EndUtc)) + return Conflict("Klinikern är redan bokad under den valda tiden."); + + var appointment = new Appointment + { + TenantId = _tenantProvider.GetCurrentTenantId(), + CreatedByUserId = _tenantProvider.GetCurrentUserId(), + PatientId = request.PatientId, + ClinicianUserId = request.ClinicianUserId, + StartUtc = request.StartUtc, + EndUtc = request.EndUtc, + Reason = request.Reason, + Location = request.Location + }; + + _db.Appointments.Add(appointment); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetById), new { id = appointment.Id }, ToResponse(appointment)); + } + + [HttpGet("{id:guid}")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task> GetById(Guid id) + { + var appointment = await _db.Appointments.FirstOrDefaultAsync(a => a.Id == id); + if (appointment is null) return NotFound(); + return ToResponse(appointment); + } + + [HttpGet("by-patient/{patientId:guid}")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task>> GetByPatient(Guid patientId) + { + var appointments = await _db.Appointments + .Where(a => a.PatientId == patientId) + .OrderBy(a => a.StartUtc) + .ToListAsync(); + return appointments.Select(ToResponse).ToList(); + } + + [HttpGet("by-clinician/{clinicianUserId}")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task>> GetByClinician( + string clinicianUserId, [FromQuery] DateOnly date) + { + var (dayStart, dayEnd) = DayBoundsUtc(date); + var appointments = await _db.Appointments + .Where(a => a.ClinicianUserId == clinicianUserId + && a.StartUtc >= dayStart && a.StartUtc < dayEnd + && a.Status != AppointmentStatus.Cancelled) + .OrderBy(a => a.StartUtc) + .ToListAsync(); + return appointments.Select(ToResponse).ToList(); + } + + /// + /// Räknar ut lediga bokningsbara luckor för en kliniker en given dag, + /// baserat på ClinicianWorkingHours minus redan bokade (icke-avbokade) tider. + /// + [HttpGet("availability")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task>> GetAvailability( + [FromQuery] string clinicianUserId, + [FromQuery] DateOnly date, + [FromQuery] int durationMinutes = 30) + { + if (durationMinutes <= 0) return BadRequest("durationMinutes måste vara positivt."); + + var workingHours = await _db.ClinicianWorkingHours + .Where(w => w.ClinicianUserId == clinicianUserId && w.DayOfWeek == date.DayOfWeek) + .ToListAsync(); + if (workingHours.Count == 0) return Ok(new List()); + + var (dayStart, dayEnd) = DayBoundsUtc(date); + var bookedAppointments = await _db.Appointments + .Where(a => a.ClinicianUserId == clinicianUserId + && a.StartUtc >= dayStart && a.StartUtc < dayEnd + && a.Status != AppointmentStatus.Cancelled) + .OrderBy(a => a.StartUtc) + .ToListAsync(); + + var slots = new List(); + var duration = TimeSpan.FromMinutes(durationMinutes); + + foreach (var window in workingHours) + { + var cursor = date.ToDateTime(window.StartTime, DateTimeKind.Utc); + var windowEnd = date.ToDateTime(window.EndTime, DateTimeKind.Utc); + + while (cursor + duration <= windowEnd) + { + var slotStart = new DateTimeOffset(cursor, TimeSpan.Zero); + var slotEnd = slotStart + duration; + + var overlaps = bookedAppointments.Any(a => a.StartUtc < slotEnd && slotStart < a.EndUtc); + if (!overlaps) + slots.Add(new AvailableSlotResponse(slotStart, slotEnd)); + + cursor += duration; + } + } + + return slots; + } + + [HttpPost("{id:guid}/reschedule")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task> Reschedule(Guid id, RescheduleAppointmentRequest request) + { + var existing = await _db.Appointments.FirstOrDefaultAsync(a => a.Id == id); + if (existing is null) return NotFound(); + if (existing.Status is AppointmentStatus.Cancelled or AppointmentStatus.Completed) + return Conflict("Kan inte boka om en avbokad eller genomförd tid."); + + if (request.NewEndUtc <= request.NewStartUtc) + return BadRequest("Sluttid måste vara efter starttid."); + + if (await HasOverlapAsync(existing.ClinicianUserId, request.NewStartUtc, request.NewEndUtc, excludeAppointmentId: existing.Id)) + return Conflict("Klinikern är redan bokad under den nya tiden."); + + existing.Status = AppointmentStatus.Cancelled; + existing.CancellationReason = "Ombokad"; + existing.CancelledAtUtc = DateTimeOffset.UtcNow; + + var rebooked = new Appointment + { + TenantId = _tenantProvider.GetCurrentTenantId(), + CreatedByUserId = _tenantProvider.GetCurrentUserId(), + PatientId = existing.PatientId, + ClinicianUserId = existing.ClinicianUserId, + StartUtc = request.NewStartUtc, + EndUtc = request.NewEndUtc, + Reason = existing.Reason, + Location = existing.Location, + RescheduledFromAppointmentId = existing.Id + }; + + _db.Appointments.Add(rebooked); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetById), new { id = rebooked.Id }, ToResponse(rebooked)); + } + + [HttpPost("{id:guid}/cancel")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task Cancel(Guid id, CancelAppointmentRequest request) + { + var appointment = await _db.Appointments.FirstOrDefaultAsync(a => a.Id == id); + if (appointment is null) return NotFound(); + if (appointment.Status == AppointmentStatus.Cancelled) return Conflict("Redan avbokad."); + + appointment.Status = AppointmentStatus.Cancelled; + appointment.CancellationReason = request.CancellationReason; + appointment.CancelledAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpPost("{id:guid}/check-in")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task CheckIn(Guid id) + { + var appointment = await _db.Appointments.FirstOrDefaultAsync(a => a.Id == id); + if (appointment is null) return NotFound(); + if (appointment.Status != AppointmentStatus.Scheduled) + return Conflict($"Kan inte checka in en tid med status {appointment.Status}."); + + appointment.Status = AppointmentStatus.CheckedIn; + await _db.SaveChangesAsync(); + return NoContent(); + } + + [HttpPost("{id:guid}/complete")] + [Authorize(Roles = "Clinician")] + public async Task Complete(Guid id) + { + var appointment = await _db.Appointments.FirstOrDefaultAsync(a => a.Id == id); + if (appointment is null) return NotFound(); + + appointment.Status = AppointmentStatus.Completed; + await _db.SaveChangesAsync(); + return NoContent(); + } + + private async Task HasOverlapAsync( + string clinicianUserId, DateTimeOffset start, DateTimeOffset end, Guid? excludeAppointmentId = null) + { + return await _db.Appointments.AnyAsync(a => + a.ClinicianUserId == clinicianUserId + && a.Status != AppointmentStatus.Cancelled + && a.Id != (excludeAppointmentId ?? Guid.Empty) + && a.StartUtc < end && start < a.EndUtc); + } + + private static (DateTimeOffset dayStart, DateTimeOffset dayEnd) DayBoundsUtc(DateOnly date) + { + var start = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + return (start, start.AddDays(1)); + } + + private static AppointmentResponse ToResponse(Appointment a) => new( + a.Id, a.PatientId, a.ClinicianUserId, a.StartUtc, a.EndUtc, + a.Status, a.Reason, a.Location, a.RescheduledFromAppointmentId); +} diff --git a/src/Rinta.Api/Controllers/AuthController.cs b/src/Rinta.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..e7ba33a --- /dev/null +++ b/src/Rinta.Api/Controllers/AuthController.cs @@ -0,0 +1,73 @@ +using Rinta.Api.DTOs; +using Rinta.Infrastructure.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace Rinta.Api.Controllers; + +/// +/// Rent JSON-baserat inloggningsflöde - servern har inget eget UI längre, +/// klientapplikationen (Rinta.Desktop, WPF) pratar bara med detta +/// API. Autentiseringen är fortfarande cookie-baserad (ASP.NET Core +/// Identity) - klienten behöver bara skicka med samma CookieContainer på +/// efterföljande anrop, vilket System.Net.Http.HttpClient/HttpClientHandler +/// gör automatiskt. Ingen SSO/extern IdP inblandad. +/// +[ApiController] +[Route("api/auth")] +public class AuthController : ControllerBase +{ + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + + public AuthController(SignInManager signInManager, UserManager userManager) + { + _signInManager = signInManager; + _userManager = userManager; + } + + [HttpPost("login")] + [AllowAnonymous] + public async Task> Login(LoginRequest request) + { + var user = await _userManager.FindByNameAsync(request.UserName); + if (user is null || !user.IsActive) + return Unauthorized("Fel användarnamn eller lösenord."); + + var result = await _signInManager.PasswordSignInAsync( + user, request.Password, isPersistent: true, lockoutOnFailure: true); + + if (result.IsLockedOut) + return StatusCode(423, "Kontot är tillfälligt låst på grund av för många felaktiga inloggningsförsök."); + if (!result.Succeeded) + return Unauthorized("Fel användarnamn eller lösenord."); + + // SignInManager har redan satt autentiseringscookien på svaret via + // HttpContext.SignInAsync - klienten ska spara den (t.ex. i en + // CookieContainer) och skicka med den på alla efterföljande anrop. + var roles = await _userManager.GetRolesAsync(user); + return new UserResponse(user.Id, user.UserName ?? "", user.DisplayName, user.Email ?? "", user.IsActive, roles); + } + + [HttpPost("logout")] + [Authorize] + public async Task Logout() + { + await _signInManager.SignOutAsync(); + return NoContent(); + } + + /// Låter klienten kolla om den fortfarande är inloggad (t.ex. vid uppstart) + /// utan att behöva fråga om användarnamn/lösenord igen. + [HttpGet("me")] + [Authorize] + public async Task> Me() + { + var user = await _userManager.GetUserAsync(User); + if (user is null) return Unauthorized(); + + var roles = await _userManager.GetRolesAsync(user); + return new UserResponse(user.Id, user.UserName ?? "", user.DisplayName, user.Email ?? "", user.IsActive, roles); + } +} diff --git a/src/Rinta.Api/Controllers/ClinicalNotesController.cs b/src/Rinta.Api/Controllers/ClinicalNotesController.cs new file mode 100644 index 0000000..cd42144 --- /dev/null +++ b/src/Rinta.Api/Controllers/ClinicalNotesController.cs @@ -0,0 +1,133 @@ +using Rinta.Api.DTOs; +using Rinta.Domain.Common; +using Rinta.Domain.Entities; +using Rinta.Infrastructure.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Rinta.Api.Controllers; + +/// +/// OBS: Det finns medvetet inget PUT/PATCH/DELETE här. Journalanteckningar +/// är append-only (se ClinicalNote-entiteten). "Ändring" = ny anteckning +/// via /correct, som länkas till originalet. +/// +[ApiController] +[Route("api/clinical-notes")] +[Authorize(Roles = "Clinician")] +public class ClinicalNotesController : ControllerBase +{ + private readonly JournalDbContext _db; + private readonly ITenantProvider _tenantProvider; + + public ClinicalNotesController(JournalDbContext db, ITenantProvider tenantProvider) + { + _db = db; + _tenantProvider = tenantProvider; + } + + [HttpGet("by-patient/{patientId:guid}")] + public async Task>> GetByPatient(Guid patientId) + { + var notes = await _db.ClinicalNotes + .Where(n => n.PatientId == patientId) + .OrderBy(n => n.CreatedAtUtc) + .ToListAsync(); + + // Explicit Read-loggning: att lista en patients hela journal är + // en känslig händelse i sig, oavsett hur många anteckningar det blir. + _db.AuditLogEntries.Add(new AuditLogEntry + { + TenantId = _tenantProvider.GetCurrentTenantId(), + UserId = _tenantProvider.GetCurrentUserId(), + UserRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "unknown", + Action = AuditAction.Read, + EntityType = nameof(ClinicalNote), + EntityId = patientId, + PatientId = patientId, + IsBreakGlassAccess = _tenantProvider.IsBreakGlassAccess(), + DetailsJson = $"{{\"noteCount\":{notes.Count}}}", + SourceIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() + }); + await _db.SaveChangesAsync(); + + return notes.Select(ToResponse).ToList(); + } + + [HttpPost] + public async Task> Create(CreateClinicalNoteRequest request) + { + var patientExists = await _db.Patients.AnyAsync(p => p.Id == request.PatientId); + if (!patientExists) return NotFound("Patient hittades inte."); + + var note = new ClinicalNote + { + TenantId = _tenantProvider.GetCurrentTenantId(), + CreatedByUserId = _tenantProvider.GetCurrentUserId(), + PatientId = request.PatientId, + EncounterId = request.EncounterId, + NoteType = request.NoteType, + Content = request.Content, + ResponsibleClinicianUserId = _tenantProvider.GetCurrentUserId(), + ResponsibleClinicianRole = request.ResponsibleClinicianRole, + WasCreatedUnderBreakGlassAccess = _tenantProvider.IsBreakGlassAccess() + }; + + _db.ClinicalNotes.Add(note); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetByPatient), new { patientId = note.PatientId }, ToResponse(note)); + } + + [HttpPost("{id:guid}/sign")] + public async Task Sign(Guid id) + { + var note = await _db.ClinicalNotes.FirstOrDefaultAsync(n => n.Id == id); + if (note is null) return NotFound(); + if (note.IsSigned) return Conflict("Anteckningen är redan signerad."); + if (note.ResponsibleClinicianUserId != _tenantProvider.GetCurrentUserId()) + return Forbid("Endast ansvarig kliniker kan signera anteckningen."); + + note.IsSigned = true; + note.SignedAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + + return NoContent(); + } + + /// + /// Skapar en NY anteckning som rättelse av en tidigare, signerad anteckning. + /// Originalanteckningen förblir orörd i sin helhet - detta är kärnan i + /// append-only-kravet. + /// + [HttpPost("correct")] + public async Task> Correct(CorrectClinicalNoteRequest request) + { + var original = await _db.ClinicalNotes.FirstOrDefaultAsync(n => n.Id == request.OriginalNoteId); + if (original is null) return NotFound("Originalanteckning hittades inte."); + + var correction = new ClinicalNote + { + TenantId = _tenantProvider.GetCurrentTenantId(), + CreatedByUserId = _tenantProvider.GetCurrentUserId(), + PatientId = original.PatientId, + EncounterId = original.EncounterId, + NoteType = original.NoteType, + Content = $"RÄTTELSE av anteckning {original.Id} ({request.ReasonForCorrection}):\n\n{request.CorrectedContent}", + ResponsibleClinicianUserId = _tenantProvider.GetCurrentUserId(), + ResponsibleClinicianRole = original.ResponsibleClinicianRole, + CorrectionOfNoteId = original.Id, + WasCreatedUnderBreakGlassAccess = _tenantProvider.IsBreakGlassAccess() + }; + + _db.ClinicalNotes.Add(correction); + await _db.SaveChangesAsync(); // interceptorn loggar Action.Correction pga CorrectionOfNoteId + + return CreatedAtAction(nameof(GetByPatient), new { patientId = correction.PatientId }, ToResponse(correction)); + } + + private static ClinicalNoteResponse ToResponse(ClinicalNote n) => new( + n.Id, n.PatientId, n.NoteType, n.Content, n.ResponsibleClinicianUserId, + n.ResponsibleClinicianRole, n.IsSigned, n.SignedAtUtc, n.CorrectionOfNoteId, n.CreatedAtUtc); +} diff --git a/src/Rinta.Api/Controllers/MedicationsController.cs b/src/Rinta.Api/Controllers/MedicationsController.cs new file mode 100644 index 0000000..cb96841 --- /dev/null +++ b/src/Rinta.Api/Controllers/MedicationsController.cs @@ -0,0 +1,99 @@ +using Rinta.Api.DTOs; +using Rinta.Domain.Entities; +using Rinta.Infrastructure.Data; +using Rinta.Infrastructure.ExternalApis; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Rinta.Api.Controllers; + +/// +/// Sökning mot extern läkemedelskälla (idag openFDA - se +/// OpenFdaMedicationLookupService för viktiga begränsningar kring att detta +/// är amerikanska preparat/engelsk text, inte ett svenskt kliniskt +/// beslutsstöd). Träffar cachas i MedicationCatalogEntry för att minska +/// antalet externa anrop och ge något att falla tillbaka på. +/// +[ApiController] +[Route("api/medications")] +[Authorize(Roles = "Clinician")] +public class MedicationsController : ControllerBase +{ + private readonly JournalDbContext _db; + private readonly IMedicationLookupService _lookupService; + + public MedicationsController(JournalDbContext db, IMedicationLookupService lookupService) + { + _db = db; + _lookupService = lookupService; + } + + /// + /// Sök läkemedel utifrån diagnos/tillstånd i fritext, t.ex. "hypertension". + /// OBS: matchar mot openFDA:s engelska indikationstexter - fungerar bäst + /// med engelska sökord eller etablerade medicinska termer. + /// + [HttpGet("search-by-diagnosis")] + public async Task>> SearchByDiagnosis( + [FromQuery] string query, [FromQuery] int limit = 10) + { + if (string.IsNullOrWhiteSpace(query)) return BadRequest("query krävs."); + + var results = await _lookupService.SearchByIndicationAsync(query, limit); + await CacheResultsAsync(results); + + return results.Select(ToResponse).ToList(); + } + + [HttpGet("search-by-name")] + public async Task>> SearchByName( + [FromQuery] string query, [FromQuery] int limit = 10) + { + if (string.IsNullOrWhiteSpace(query)) return BadRequest("query krävs."); + + var results = await _lookupService.SearchByNameAsync(query, limit); + await CacheResultsAsync(results); + + return results.Select(ToResponse).ToList(); + } + + /// Läkemedel som redan finns i den lokala cachen - fungerar utan extern åtkomst. + [HttpGet("cached")] + public async Task>> SearchCached([FromQuery] string query) + { + var results = await _db.MedicationCatalogEntries + .Where(m => m.GenericName.ToLower().Contains(query.ToLower()) + || (m.BrandName != null && m.BrandName.ToLower().Contains(query.ToLower()))) + .Take(20) + .ToListAsync(); + + return results.Select(m => new MedicationSearchResponse( + m.GenericName, m.BrandName, m.IndicationsText, m.SourceApi, m.SourceRecordId)).ToList(); + } + + private async Task CacheResultsAsync(List results) + { + foreach (var result in results) + { + if (result.SourceRecordId is null) continue; + + var exists = await _db.MedicationCatalogEntries.AnyAsync(m => + m.SourceApi == result.SourceApi && m.SourceRecordId == result.SourceRecordId); + if (exists) continue; + + _db.MedicationCatalogEntries.Add(new MedicationCatalogEntry + { + SourceApi = result.SourceApi, + SourceRecordId = result.SourceRecordId, + GenericName = result.GenericName, + BrandName = result.BrandName, + IndicationsText = result.IndicationsText + }); + } + await _db.SaveChangesAsync(); + } + + private static MedicationSearchResponse ToResponse(MedicationLookupResult r) => new( + r.GenericName, r.BrandName, r.IndicationsText, r.SourceApi, r.SourceRecordId); +} diff --git a/src/Rinta.Api/Controllers/PatientsController.cs b/src/Rinta.Api/Controllers/PatientsController.cs new file mode 100644 index 0000000..82c5569 --- /dev/null +++ b/src/Rinta.Api/Controllers/PatientsController.cs @@ -0,0 +1,179 @@ +using Rinta.Api.DTOs; +using Rinta.Domain.Common; +using Rinta.Domain.Entities; +using Rinta.Infrastructure.Data; +using Rinta.Infrastructure.Security; +using Rinta.Infrastructure.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Rinta.Api.Controllers; + +[ApiController] +[Route("api/patients")] +[Authorize] +public class PatientsController : ControllerBase +{ + private readonly JournalDbContext _db; + private readonly ITenantProvider _tenantProvider; + private readonly IIdentifierHasher _identifierHasher; + private readonly PatientService _patientService; + + public PatientsController( + JournalDbContext db, ITenantProvider tenantProvider, IIdentifierHasher identifierHasher, PatientService patientService) + { + _db = db; + _tenantProvider = tenantProvider; + _identifierHasher = identifierHasher; + _patientService = patientService; + } + + [HttpPost] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task> Create(CreatePatientRequest request) + { + var result = await _patientService.CreateAsync(new CreatePatientCommand( + request.PersonalIdentityNumber, request.FirstName, request.LastName, request.DateOfBirth, + request.Sex, request.PhoneNumber, request.Email, request.Address, request.HasProtectedIdentity)); + + if (!result.Succeeded) + return BadRequest(result.ErrorMessage); // täcker både valideringsfel och dubblettkonflikt + + return CreatedAtAction(nameof(GetById), new { id = result.Patient!.Id }, ToResponse(result.Patient)); + } + + + [HttpPut("{id:guid}")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task> Update(Guid id, UpdatePatientRequest request) + { + // OBS: till skillnad från ClinicalNote/Prescription är patientens + // grunddata INTE append-only - manuellt inmatade uppgifter (namn, + // kontaktväg, adress) måste kunna rättas eftersom ni saknar en + // auktoritativ källa (SPAR) att verifiera mot. Ändringen loggas + // ändå automatiskt av AuditingSaveChangesInterceptor. + var patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == id); + if (patient is null) return NotFound(); + + patient.FirstName = request.FirstName; + patient.LastName = request.LastName; + patient.Sex = request.Sex; + patient.PhoneNumber = request.PhoneNumber; + patient.Email = request.Email; + patient.Address = request.Address; + patient.HasProtectedIdentity = request.HasProtectedIdentity; + patient.UpdatedAtUtc = DateTimeOffset.UtcNow; + patient.UpdatedByUserId = _tenantProvider.GetCurrentUserId(); + + await _db.SaveChangesAsync(); + return ToResponse(patient); + } + + /// + /// Kopplar ett riktigt, Luhn-validerat personnummer till en patient som + /// tidigare registrerats med reservnummer (t.ex. efter att en + /// medvetslös patient identifierats, eller ett nyfött barn tilldelats + /// personnummer). Reservnumret sparas i PreviousReservationNumber för + /// spårbarhet - det försvinner aldrig ur historiken. + /// + [HttpPost("{id:guid}/confirm-identity")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task> ConfirmIdentity(Guid id, ConfirmIdentityRequest request) + { + var patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == id); + if (patient is null) return NotFound(); + + if (!PersonnummerValidator.TryValidate(request.PersonalIdentityNumber, out var identifierType, out var normalized)) + return BadRequest("Personnumret/samordningsnumret klarar inte Luhn-kontrollen eller har fel format."); + + var newHash = _identifierHasher.Hash(normalized); + var duplicateExists = await _db.Patients.AnyAsync(p => p.Id != id && p.PersonalIdentityNumberHash == newHash); + if (duplicateExists) + return Conflict("En annan patient är redan registrerad med detta personnummer."); + + if (patient.IdentityType == SwedishIdentifierType.ReservationNumber) + patient.PreviousReservationNumber = patient.PersonalIdentityNumber; + + patient.PersonalIdentityNumber = normalized; + patient.PersonalIdentityNumberHash = newHash; + patient.IdentityType = identifierType; + patient.IsIdentityConfirmed = true; + patient.UpdatedAtUtc = DateTimeOffset.UtcNow; + patient.UpdatedByUserId = _tenantProvider.GetCurrentUserId(); + + await _db.SaveChangesAsync(); + return ToResponse(patient); + } + + [HttpGet("{id:guid}")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task> GetById(Guid id) + { + // Query filter i JournalDbContext säkerställer redan tenant-isolering här. + var patient = await _db.Patients.FirstOrDefaultAsync(p => p.Id == id); + if (patient is null) return NotFound(); + + await LogReadAsync(patient.Id, patient.Id); + + return ToResponse(patient); + } + + [HttpGet("search")] + [Authorize(Roles = "Clinician,Receptionist,Admin")] + public async Task>> Search( + [FromQuery] string? lastName, [FromQuery] DateOnly? dateOfBirth) + { + var query = _db.Patients.AsQueryable(); + if (!string.IsNullOrWhiteSpace(lastName)) + query = query.Where(p => p.LastName.ToLower() == lastName.ToLower()); + if (dateOfBirth.HasValue) + query = query.Where(p => p.DateOfBirth == dateOfBirth.Value); + + var results = await query.Take(50).ToListAsync(); + + // En sökträff räknas fortfarande som åtkomst till patientens grunddata - + // logga varje träff, inte bara enskild GetById. + foreach (var p in results) + await LogReadAsync(p.Id, p.Id, skipSave: true); + await _db.SaveChangesAsync(); + + return results.Select(ToResponse).ToList(); + } + + private async Task LogReadAsync(Guid entityId, Guid patientId, bool skipSave = false) + { + _db.AuditLogEntries.Add(new AuditLogEntry + { + TenantId = _tenantProvider.GetCurrentTenantId(), + UserId = _tenantProvider.GetCurrentUserId(), + UserRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "unknown", + Action = AuditAction.Read, + EntityType = nameof(Patient), + EntityId = entityId, + PatientId = patientId, + IsBreakGlassAccess = _tenantProvider.IsBreakGlassAccess(), + SourceIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() + }); + if (!skipSave) await _db.SaveChangesAsync(); + } + + private static PatientResponse ToResponse(Patient p) => new( + p.Id, + MaskPersonalIdentityNumber(p.PersonalIdentityNumber), + p.IdentityType, + p.IsIdentityConfirmed, + p.FirstName, + p.LastName, + p.DateOfBirth, + p.Sex, + p.PhoneNumber, + p.Email, + p.HasProtectedIdentity); + + private static string MaskPersonalIdentityNumber(string identifier) + { + var dashIndex = identifier.IndexOf('-'); + return dashIndex > 0 ? $"{identifier[..dashIndex]}-****" : "****"; + } +} diff --git a/src/Rinta.Api/Controllers/PrescriptionsController.cs b/src/Rinta.Api/Controllers/PrescriptionsController.cs new file mode 100644 index 0000000..bfc2d8d --- /dev/null +++ b/src/Rinta.Api/Controllers/PrescriptionsController.cs @@ -0,0 +1,164 @@ +using Rinta.Api.DTOs; +using Rinta.Domain.Common; +using Rinta.Domain.Entities; +using Rinta.Infrastructure.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Rinta.Api.Controllers; + +/// +/// OBS: precis som ClinicalNotesController finns inget PUT/PATCH här. +/// En ordination sätts ut (Discontinue) och ersätts ev. av en ny (Replace) - +/// den ursprungliga raden ändras aldrig. +/// +[ApiController] +[Route("api/prescriptions")] +[Authorize(Roles = "Clinician")] +public class PrescriptionsController : ControllerBase +{ + private readonly JournalDbContext _db; + private readonly ITenantProvider _tenantProvider; + + public PrescriptionsController(JournalDbContext db, ITenantProvider tenantProvider) + { + _db = db; + _tenantProvider = tenantProvider; + } + + [HttpGet("by-patient/{patientId:guid}")] + public async Task>> GetByPatient(Guid patientId) + { + var prescriptions = await _db.Prescriptions + .Where(p => p.PatientId == patientId) + .OrderBy(p => p.CreatedAtUtc) + .ToListAsync(); + + _db.AuditLogEntries.Add(new AuditLogEntry + { + TenantId = _tenantProvider.GetCurrentTenantId(), + UserId = _tenantProvider.GetCurrentUserId(), + UserRole = User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "unknown", + Action = AuditAction.Read, + EntityType = nameof(Prescription), + EntityId = patientId, + PatientId = patientId, + IsBreakGlassAccess = _tenantProvider.IsBreakGlassAccess(), + DetailsJson = $"{{\"prescriptionCount\":{prescriptions.Count}}}", + SourceIpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() + }); + await _db.SaveChangesAsync(); + + return prescriptions.Select(ToResponse).ToList(); + } + + [HttpPost] + public async Task> Create(CreatePrescriptionRequest request) + { + var patientExists = await _db.Patients.AnyAsync(p => p.Id == request.PatientId); + if (!patientExists) return NotFound("Patient hittades inte."); + + var prescription = BuildFromRequest(request); + _db.Prescriptions.Add(prescription); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetByPatient), new { patientId = prescription.PatientId }, ToResponse(prescription)); + } + + [HttpPost("{id:guid}/sign")] + public async Task Sign(Guid id) + { + var prescription = await _db.Prescriptions.FirstOrDefaultAsync(p => p.Id == id); + if (prescription is null) return NotFound(); + if (prescription.IsSigned) return Conflict("Ordinationen är redan signerad."); + if (prescription.PrescribingClinicianUserId != _tenantProvider.GetCurrentUserId()) + return Forbid("Endast förskrivaren kan signera ordinationen."); + + prescription.IsSigned = true; + prescription.SignedAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(); + + return NoContent(); + } + + [HttpPost("{id:guid}/discontinue")] + public async Task Discontinue(Guid id, DiscontinuePrescriptionRequest request) + { + var prescription = await _db.Prescriptions.FirstOrDefaultAsync(p => p.Id == id); + if (prescription is null) return NotFound(); + if (prescription.Status != PrescriptionStatus.Active) + return Conflict($"Kan inte sätta ut en ordination med status {prescription.Status}."); + + prescription.Status = PrescriptionStatus.Discontinued; + prescription.DiscontinuedAtUtc = DateTimeOffset.UtcNow; + prescription.DiscontinuationReason = request.DiscontinuationReason; + await _db.SaveChangesAsync(); + + return NoContent(); + } + + /// + /// Sätter ut en befintlig ordination och skapar en ny, länkad, med + /// justerad dos/frekvens/väg. Ursprungsordinationen förblir orörd. + /// + [HttpPost("{id:guid}/replace")] + public async Task> Replace(Guid id, ReplacePrescriptionRequest request) + { + var original = await _db.Prescriptions.FirstOrDefaultAsync(p => p.Id == id); + if (original is null) return NotFound(); + if (original.Status != PrescriptionStatus.Active) + return Conflict($"Kan inte ersätta en ordination med status {original.Status}."); + + original.Status = PrescriptionStatus.Discontinued; + original.DiscontinuedAtUtc = DateTimeOffset.UtcNow; + original.DiscontinuationReason = $"Ersatt: {request.ReasonForChange}"; + + var replacement = new Prescription + { + TenantId = _tenantProvider.GetCurrentTenantId(), + CreatedByUserId = _tenantProvider.GetCurrentUserId(), + PatientId = original.PatientId, + DrugName = original.DrugName, + AtcCode = original.AtcCode, + MedicationCatalogEntryId = original.MedicationCatalogEntryId, + Dosage = request.NewDosage, + Frequency = request.NewFrequency, + Route = request.NewRoute ?? original.Route, + DiagnosisCode = original.DiagnosisCode, + DiagnosisDescription = original.DiagnosisDescription, + StartDate = DateOnly.FromDateTime(DateTime.UtcNow), + PlannedEndDate = original.PlannedEndDate, + PrescribingClinicianUserId = _tenantProvider.GetCurrentUserId(), + ReplacesPrescriptionId = original.Id + }; + + _db.Prescriptions.Add(replacement); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetByPatient), new { patientId = replacement.PatientId }, ToResponse(replacement)); + } + + private Prescription BuildFromRequest(CreatePrescriptionRequest request) => new() + { + TenantId = _tenantProvider.GetCurrentTenantId(), + CreatedByUserId = _tenantProvider.GetCurrentUserId(), + PatientId = request.PatientId, + DrugName = request.DrugName, + AtcCode = request.AtcCode, + MedicationCatalogEntryId = request.MedicationCatalogEntryId, + Dosage = request.Dosage, + Frequency = request.Frequency, + Route = request.Route, + DiagnosisCode = request.DiagnosisCode, + DiagnosisDescription = request.DiagnosisDescription, + StartDate = request.StartDate, + PlannedEndDate = request.PlannedEndDate, + PrescribingClinicianUserId = _tenantProvider.GetCurrentUserId() + }; + + private static PrescriptionResponse ToResponse(Prescription p) => new( + p.Id, p.PatientId, p.DrugName, p.AtcCode, p.Dosage, p.Frequency, p.Route, + p.DiagnosisCode, p.DiagnosisDescription, p.StartDate, p.PlannedEndDate, + p.PrescribingClinicianUserId, p.IsSigned, p.Status, p.ReplacesPrescriptionId, p.CreatedAtUtc); +} diff --git a/src/Rinta.Api/Controllers/UsersController.cs b/src/Rinta.Api/Controllers/UsersController.cs new file mode 100644 index 0000000..bc43473 --- /dev/null +++ b/src/Rinta.Api/Controllers/UsersController.cs @@ -0,0 +1,96 @@ +using Rinta.Api.DTOs; +using Rinta.Domain.Common; +using Rinta.Infrastructure.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Rinta.Api.Controllers; + +/// +/// Kontohantering. Eftersom det inte finns någon extern SSO/IdP längre, +/// och självregistrering INTE är lämpligt i ett journalsystem, kan bara en +/// Admin skapa nya användare - och alltid inom sin egen tenant, aldrig +/// på tvärs (en admin på BertinA kan inte skapa en användare hos en annan +/// vårdgivare). Att provisionera den allra första tenanten/admin-kontot +/// sker separat, se README ("Kom igång - första admin-kontot"). +/// +[ApiController] +[Route("api/users")] +[Authorize(Roles = "Admin")] +public class UsersController : ControllerBase +{ + private static readonly string[] ValidRoles = { "Clinician", "Receptionist", "Admin" }; + + private readonly UserManager _userManager; + private readonly ITenantProvider _tenantProvider; + + public UsersController(UserManager userManager, ITenantProvider tenantProvider) + { + _userManager = userManager; + _tenantProvider = tenantProvider; + } + + [HttpGet] + public async Task>> List() + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var users = await _userManager.Users.Where(u => u.TenantId == tenantId).ToListAsync(); + + var responses = new List(); + foreach (var user in users) + { + var roles = await _userManager.GetRolesAsync(user); + responses.Add(new UserResponse(user.Id, user.UserName ?? "", user.DisplayName, user.Email ?? "", user.IsActive, roles)); + } + return responses; + } + + [HttpPost] + public async Task> Create(CreateUserRequest request) + { + if (!ValidRoles.Contains(request.Role)) + return BadRequest($"Ogiltig roll. Giltiga värden: {string.Join(", ", ValidRoles)}."); + + var user = new ApplicationUser + { + UserName = request.UserName, + Email = request.Email, + DisplayName = request.DisplayName, + TenantId = _tenantProvider.GetCurrentTenantId(), + IsActive = true, + // I produktion: sätt EmailConfirmed via en riktig e-postverifiering. + // Här litar vi på att en admin fysiskt skapar kontot åt en känd kollega. + EmailConfirmed = true + }; + + var createResult = await _userManager.CreateAsync(user, request.TemporaryPassword); + if (!createResult.Succeeded) + return BadRequest(createResult.Errors.Select(e => e.Description)); + + await _userManager.AddToRoleAsync(user, request.Role); + + // Tvinga lösenordsbyte vid första inloggning genom att sätta ut en + // extremt kort giltighetstid vore ett alternativ, men enklast och + // tydligast: dokumentera i README att admin kommunicerar det + // tillfälliga lösenordet muntligt/manuellt och instruerar användaren + // att byta det direkt (ingen "byt lösenord vid första inloggning"- + // funktion är byggd ännu - se roadmap). + + return CreatedAtAction(nameof(List), null, new UserResponse( + user.Id, user.UserName, user.DisplayName, user.Email, user.IsActive, new List { request.Role })); + } + + [HttpPost("{id}/deactivate")] + public async Task Deactivate(string id) + { + var tenantId = _tenantProvider.GetCurrentTenantId(); + var user = await _userManager.Users.FirstOrDefaultAsync(u => u.Id == id && u.TenantId == tenantId); + if (user is null) return NotFound(); + + user.IsActive = false; + await _userManager.UpdateAsync(user); + return NoContent(); + } +} diff --git a/src/Rinta.Api/DTOs/AppointmentDtos.cs b/src/Rinta.Api/DTOs/AppointmentDtos.cs new file mode 100644 index 0000000..9a5ee28 --- /dev/null +++ b/src/Rinta.Api/DTOs/AppointmentDtos.cs @@ -0,0 +1,39 @@ +using Rinta.Domain.Entities; +using System.ComponentModel.DataAnnotations; + +namespace Rinta.Api.DTOs; + +public record CreateAppointmentRequest( + [Required] Guid PatientId, + [Required] string ClinicianUserId, + [Required] DateTimeOffset StartUtc, + [Required] DateTimeOffset EndUtc, + string? Reason, + string? Location +); + +public record RescheduleAppointmentRequest( + [Required] DateTimeOffset NewStartUtc, + [Required] DateTimeOffset NewEndUtc +); + +public record CancelAppointmentRequest( + [Required] string CancellationReason +); + +public record AppointmentResponse( + Guid Id, + Guid PatientId, + string ClinicianUserId, + DateTimeOffset StartUtc, + DateTimeOffset EndUtc, + AppointmentStatus Status, + string? Reason, + string? Location, + Guid? RescheduledFromAppointmentId +); + +public record AvailableSlotResponse( + DateTimeOffset StartUtc, + DateTimeOffset EndUtc +); diff --git a/src/Rinta.Api/DTOs/AuthDtos.cs b/src/Rinta.Api/DTOs/AuthDtos.cs new file mode 100644 index 0000000..5943f76 --- /dev/null +++ b/src/Rinta.Api/DTOs/AuthDtos.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Rinta.Api.DTOs; + +public record LoginRequest( + [Required] string UserName, + [Required] string Password, + string? ReturnUrl +); + +public record CreateUserRequest( + [Required] string UserName, + [Required, EmailAddress] string Email, + [Required] string DisplayName, + [Required] string TemporaryPassword, + [Required] string Role // "Clinician" | "Receptionist" | "Admin" +); + +public record UserResponse( + string Id, + string UserName, + string DisplayName, + string Email, + bool IsActive, + IList Roles +); diff --git a/src/Rinta.Api/DTOs/ClinicalNoteDtos.cs b/src/Rinta.Api/DTOs/ClinicalNoteDtos.cs new file mode 100644 index 0000000..3a22ce4 --- /dev/null +++ b/src/Rinta.Api/DTOs/ClinicalNoteDtos.cs @@ -0,0 +1,35 @@ +using Rinta.Domain.Entities; +using System.ComponentModel.DataAnnotations; + +namespace Rinta.Api.DTOs; + +public record CreateClinicalNoteRequest( + [Required] Guid PatientId, + Guid? EncounterId, + [Required] ClinicalNoteType NoteType, + [Required, MinLength(1)] string Content, + [Required] string ResponsibleClinicianRole +); + +public record CorrectClinicalNoteRequest( + [Required] Guid OriginalNoteId, + [Required, MinLength(1)] string CorrectedContent, + [Required] string ReasonForCorrection +); + +public record SignClinicalNoteRequest( + [Required] Guid NoteId +); + +public record ClinicalNoteResponse( + Guid Id, + Guid PatientId, + ClinicalNoteType NoteType, + string Content, + string ResponsibleClinicianUserId, + string ResponsibleClinicianRole, + bool IsSigned, + DateTimeOffset? SignedAtUtc, + Guid? CorrectionOfNoteId, + DateTimeOffset CreatedAtUtc +); diff --git a/src/Rinta.Api/DTOs/PatientDtos.cs b/src/Rinta.Api/DTOs/PatientDtos.cs new file mode 100644 index 0000000..496e95d --- /dev/null +++ b/src/Rinta.Api/DTOs/PatientDtos.cs @@ -0,0 +1,52 @@ +using Rinta.Domain.Common; +using System.ComponentModel.DataAnnotations; + +namespace Rinta.Api.DTOs; + +public record CreatePatientRequest( + /// + /// Personnummer eller samordningsnummer. Lämna TOMT/null om identiteten + /// inte är känd/bekräftad (t.ex. medvetslös patient, nyfödd) - då + /// genereras automatiskt ett lokalt reservnummer istället. + /// + string? PersonalIdentityNumber, + [Required] string FirstName, + [Required] string LastName, + [Required] DateOnly DateOfBirth, + string? Sex, + string? PhoneNumber, + string? Email, + string? Address, + bool HasProtectedIdentity +); + +public record UpdatePatientRequest( + [Required] string FirstName, + [Required] string LastName, + string? Sex, + string? PhoneNumber, + string? Email, + string? Address, + bool HasProtectedIdentity +); + +/// Kopplar ett riktigt, verifierat personnummer till en patient som +/// tidigare registrerats med reservnummer. +public record ConfirmIdentityRequest( + [Required] string PersonalIdentityNumber +); + +public record PatientResponse( + Guid Id, + string MaskedPersonalIdentityNumber, + SwedishIdentifierType IdentityType, + bool IsIdentityConfirmed, + string FirstName, + string LastName, + DateOnly DateOfBirth, + string? Sex, + string? PhoneNumber, + string? Email, + bool HasProtectedIdentity +); + diff --git a/src/Rinta.Api/DTOs/PrescriptionDtos.cs b/src/Rinta.Api/DTOs/PrescriptionDtos.cs new file mode 100644 index 0000000..e26b472 --- /dev/null +++ b/src/Rinta.Api/DTOs/PrescriptionDtos.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; + +namespace Rinta.Api.DTOs; + +public record MedicationSearchResponse( + string GenericName, + string? BrandName, + string? IndicationsText, + string SourceApi, + string? SourceRecordId +); + +public record CreatePrescriptionRequest( + [Required] Guid PatientId, + [Required] string DrugName, + string? AtcCode, + Guid? MedicationCatalogEntryId, + [Required] string Dosage, + [Required] string Frequency, + string? Route, + string? DiagnosisCode, + string? DiagnosisDescription, + [Required] DateOnly StartDate, + DateOnly? PlannedEndDate +); + +public record DiscontinuePrescriptionRequest( + [Required] string DiscontinuationReason +); + +public record ReplacePrescriptionRequest( + [Required] string NewDosage, + [Required] string NewFrequency, + string? NewRoute, + [Required] string ReasonForChange +); + +public record PrescriptionResponse( + Guid Id, + Guid PatientId, + string DrugName, + string? AtcCode, + string Dosage, + string Frequency, + string? Route, + string? DiagnosisCode, + string? DiagnosisDescription, + DateOnly StartDate, + DateOnly? PlannedEndDate, + string PrescribingClinicianUserId, + bool IsSigned, + Rinta.Domain.Entities.PrescriptionStatus Status, + Guid? ReplacesPrescriptionId, + DateTimeOffset CreatedAtUtc +); diff --git a/src/Rinta.Api/Program.cs b/src/Rinta.Api/Program.cs new file mode 100644 index 0000000..ecb3050 --- /dev/null +++ b/src/Rinta.Api/Program.cs @@ -0,0 +1,160 @@ +using Rinta.Domain.Common; +using Rinta.Domain.Entities; +using Rinta.Infrastructure.Data; +using Rinta.Infrastructure.ExternalApis; +using Rinta.Infrastructure.Identity; +using Rinta.Infrastructure.Security; +using Rinta.Infrastructure.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Rent JSON-API - ingen egen webb-UI. Klienten är Rinta.Desktop +// (WPF), ett fristående program som körs på användarens dator och pratar +// med det här API:t över HTTP. +builder.Services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + }); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +builder.Services.AddDbContext((serviceProvider, options) => +{ + var connectionString = builder.Configuration.GetConnectionString("JournalDb") + ?? throw new InvalidOperationException("ConnectionStrings:JournalDb saknas i konfigurationen."); + + // ServerVersion.AutoDetect kräver en riktig anslutning vid uppstart för + // att fråga servern om dess version - fungerar med både MariaDB och + // MySQL. Om ni vet exakt version kan ni hårdkoda den istället för att + // slippa den extra anslutningen vid varje omstart, t.ex.: + // new MariaDbServerVersion(new Version(10, 11, 0)) + options.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)); + options.AddInterceptors(serviceProvider.GetRequiredService()); +}); + +// openFDA: publikt, ingen API-nyckel krävs för låg volym. Se +// OpenFdaMedicationLookupService för viktiga begränsningar (amerikanska +// preparat, engelsk indikationstext - inte ett svenskt beslutsstöd). +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.fda.gov/"); + client.Timeout = TimeSpan.FromSeconds(10); +}); + +// Navet (Skatteverkets folkbokföringssystem) - INTE aktivt kopplat än. +// Se INavetLookupService/NavetLookupService för vad som krävs innan det +// går att slå på (godkänd ansökan + certifikat från Skatteverket). +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri(builder.Configuration["Navet:BaseUrl"] ?? "https://api.test.skatteverket.se/"); + client.Timeout = TimeSpan.FromSeconds(10); +}); + +// Lokala konton istället för extern SSO (Keycloak/Authentik). Se +// ApplicationUser, TenantClaimsPrincipalFactory och README ("Autentisering"). +builder.Services.AddIdentity(options => + { + options.Password.RequiredLength = 12; + options.Password.RequireNonAlphanumeric = true; + options.Password.RequireUppercase = true; + options.Password.RequireDigit = true; + options.Lockout.MaxFailedAccessAttempts = 5; + options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); + options.User.RequireUniqueEmail = true; + }) + .AddEntityFrameworkStores() + .AddClaimsPrincipalFactory() + .AddDefaultTokenProviders(); + +builder.Services.ConfigureApplicationCookie(options => +{ + options.ExpireTimeSpan = TimeSpan.FromHours(8); + options.SlidingExpiration = true; + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Lax; // Strict funkar inte bra för en fristående klientapp + + // Ingen inloggningssida att omdirigera till längre - servern har inget + // eget UI. Returnera 401/403 som ren JSON-vänlig statuskod istället för + // Identitys standardbeteende (302-redirect till en HTML-sida). + options.Events.OnRedirectToLogin = context => + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return Task.CompletedTask; + }; + options.Events.OnRedirectToAccessDenied = context => + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return Task.CompletedTask; + }; +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); + await SeedDevelopmentDataAsync(app); +} + +app.UseHttpsRedirection(); +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); + +/// +/// Skapar en första tenant + admin-konto om databasen är helt tom, ENDAST i +/// utvecklingsmiljö. Utan extern SSO finns ingen "första inloggning" annars - +/// se README ("Kom igång - första admin-kontot") för hur detta ska göras i +/// produktion (ett engångs-CLI-kommando, inte automatisk körning vid boot). +/// +static async Task SeedDevelopmentDataAsync(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + + if (await db.Tenants.AnyAsync()) return; + + var tenant = new Tenant { Name = "Utvecklingsmiljö - Testklinik" }; + db.Tenants.Add(tenant); + await db.SaveChangesAsync(); + + var roleManager = scope.ServiceProvider.GetRequiredService>(); + foreach (var role in new[] { "Admin", "Clinician", "Receptionist" }) + { + if (!await roleManager.RoleExistsAsync(role)) + await roleManager.CreateAsync(new IdentityRole(role)); + } + + var userManager = scope.ServiceProvider.GetRequiredService>(); + var adminPassword = $"Dev-{Guid.NewGuid().ToString("N")[..12]}!"; + var admin = new ApplicationUser + { + UserName = "admin", + Email = "admin@example.invalid", + DisplayName = "Utvecklingsadmin", + TenantId = tenant.Id, + EmailConfirmed = true, + IsActive = true + }; + await userManager.CreateAsync(admin, adminPassword); + await userManager.AddToRoleAsync(admin, "Admin"); + + app.Logger.LogWarning( + "=== UTVECKLINGSKONTO SKAPAT === Användarnamn: admin | Lösenord: {Password} | " + + "Detta sker ENDAST i Development-miljö och ska ALDRIG hända i produktion.", + adminPassword); +} diff --git a/src/Rinta.Api/Properties/launchSettings.json b/src/Rinta.Api/Properties/launchSettings.json new file mode 100644 index 0000000..1ff7a2c --- /dev/null +++ b/src/Rinta.Api/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:5080", + "sslPort": 44380 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "Account/Login", + "applicationUrl": "http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "Account/Login", + "applicationUrl": "https://localhost:44380;http://localhost:5080", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "Account/Login", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Rinta.Api/Rinta.Api.csproj b/src/Rinta.Api/Rinta.Api.csproj new file mode 100644 index 0000000..a91fa56 --- /dev/null +++ b/src/Rinta.Api/Rinta.Api.csproj @@ -0,0 +1,19 @@ + + + net8.0 + enable + enable + journalsystem-api + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/src/Rinta.Api/appsettings.json b/src/Rinta.Api/appsettings.json new file mode 100644 index 0000000..c8cc7e8 --- /dev/null +++ b/src/Rinta.Api/appsettings.json @@ -0,0 +1,24 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "JournalDb": "Server=localhost;Port=3306;Database=rinta;User=rinta_app;Password=CHANGE_ME" + }, + "Encryption": { + "_comment": "Base64-kodade nycklar. GENERERA NYA och lägg i user-secrets/Key Vault - använd ALDRIG dessa placeholders i produktion.", + "ColumnKey": "REPLACE_WITH_BASE64_ENCODED_256BIT_KEY", + "HashKey": "REPLACE_WITH_ANOTHER_BASE64_ENCODED_256BIT_KEY" + }, + "Navet": { + "_comment": "INTE aktiv - kräver godkänd ansökan hos Skatteverket + klientcertifikat. Se NavetLookupService.cs för detaljer.", + "BaseUrl": "https://api.test.skatteverket.se/", + "OrderId": "", + "ClientCertificatePath": "", + "ClientCertificatePassword": "" + } +} + diff --git a/src/Rinta.Desktop/App.xaml b/src/Rinta.Desktop/App.xaml new file mode 100644 index 0000000..2da9804 --- /dev/null +++ b/src/Rinta.Desktop/App.xaml @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Rinta.Desktop/App.xaml.cs b/src/Rinta.Desktop/App.xaml.cs new file mode 100644 index 0000000..d220b6b --- /dev/null +++ b/src/Rinta.Desktop/App.xaml.cs @@ -0,0 +1,45 @@ +using System.IO; +using System.Text.Json; +using System.Windows; +using Rinta.Desktop.Services; +using Rinta.Desktop.Windows; + +namespace Rinta.Desktop; + +public partial class App : Application +{ + /// Delas av alla fönster under hela programmets körning - + /// håller i CookieContainer:n som bär inloggningssessionen. + public static ApiClient ApiClient { get; private set; } = null!; + + protected override void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + var baseUrl = LoadApiBaseUrl(); + ApiClient = new ApiClient(baseUrl); + + var loginWindow = new LoginWindow(); + loginWindow.Show(); + } + + private static string LoadApiBaseUrl() + { + var configPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); + if (File.Exists(configPath)) + { + try + { + using var stream = File.OpenRead(configPath); + var doc = JsonDocument.Parse(stream); + if (doc.RootElement.TryGetProperty("ApiBaseUrl", out var urlProp)) + return urlProp.GetString() ?? "https://localhost:44380/"; + } + catch (JsonException) + { + // Trasig config - falla tillbaka på standardvärdet nedan istället för att krascha vid start. + } + } + return "https://localhost:44380/"; + } +} diff --git a/src/Rinta.Desktop/Assets/rinta-icon.ico b/src/Rinta.Desktop/Assets/rinta-icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3d374262fb57145d26aca6c7b9138e7443fae113 GIT binary patch literal 21025 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%0Aj#sGSN8hQXvW^%SLaDV*1wyJ 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 O2YC*++D-m9Y4RVltEpfB literal 0 HcmV?d00001 diff --git a/src/Rinta.Desktop/Assets/rinta-logo.png b/src/Rinta.Desktop/Assets/rinta-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..30af8bb1db929f669cc4fd0101e11ea0b7866c1b GIT binary patch literal 40206 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

EAy+v5(6ghm4ljWSLXY^YT0tLR>PX^ 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 literal 0 HcmV?d00001 diff --git a/src/Rinta.Desktop/Assets/rinta-logo.svg b/src/Rinta.Desktop/Assets/rinta-logo.svg new file mode 100644 index 0000000..d14fc06 --- /dev/null +++ b/src/Rinta.Desktop/Assets/rinta-logo.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Rinta + + + + + + + + JOURNALSYSTEM + + + + + + diff --git a/src/Rinta.Desktop/Models/ApiModels.cs b/src/Rinta.Desktop/Models/ApiModels.cs new file mode 100644 index 0000000..ebecea5 --- /dev/null +++ b/src/Rinta.Desktop/Models/ApiModels.cs @@ -0,0 +1,132 @@ +namespace Rinta.Desktop.Models; + +public record UserInfo(string Id, string UserName, string DisplayName, string Email, bool IsActive, List Roles); + +public record CreateUserRequest( + string UserName, + string Email, + string DisplayName, + string TemporaryPassword, + string Role +); + +public enum ClinicalNoteType +{ + Anamnes, + StatusPresens, + Bedomning, + Atgardsplan, + Epikris, + TelefonRadgivning, + Ovrigt +} + +public record CreateClinicalNoteRequest( + Guid PatientId, + Guid? EncounterId, + ClinicalNoteType NoteType, + string Content, + string ResponsibleClinicianRole +); + +public record CorrectClinicalNoteRequest( + Guid OriginalNoteId, + string CorrectedContent, + string ReasonForCorrection +); + +public record ClinicalNoteResponse( + Guid Id, + Guid PatientId, + ClinicalNoteType NoteType, + string Content, + string ResponsibleClinicianUserId, + string ResponsibleClinicianRole, + bool IsSigned, + DateTimeOffset? SignedAtUtc, + Guid? CorrectionOfNoteId, + DateTimeOffset CreatedAtUtc +); + +public enum PrescriptionStatus +{ + Active, + Discontinued, + Completed +} + +public record CreatePrescriptionRequest( + Guid PatientId, + string DrugName, + string? AtcCode, + Guid? MedicationCatalogEntryId, + string Dosage, + string Frequency, + string? Route, + string? DiagnosisCode, + string? DiagnosisDescription, + DateOnly StartDate, + DateOnly? PlannedEndDate +); + +public record DiscontinuePrescriptionRequest(string DiscontinuationReason); + +public record ReplacePrescriptionRequest( + string NewDosage, + string NewFrequency, + string? NewRoute, + string ReasonForChange +); + +public record PrescriptionResponse( + Guid Id, + Guid PatientId, + string DrugName, + string? AtcCode, + string Dosage, + string Frequency, + string? Route, + string? DiagnosisCode, + string? DiagnosisDescription, + DateOnly StartDate, + DateOnly? PlannedEndDate, + string PrescribingClinicianUserId, + bool IsSigned, + PrescriptionStatus Status, + Guid? ReplacesPrescriptionId, + DateTimeOffset CreatedAtUtc +); + +public record MedicationSearchResponse( + string GenericName, + string? BrandName, + string? IndicationsText, + string SourceApi, + string? SourceRecordId +); + +public record CreatePatientRequest( + string? PersonalIdentityNumber, + string FirstName, + string LastName, + DateOnly DateOfBirth, + string? Sex, + string? PhoneNumber, + string? Email, + string? Address, + bool HasProtectedIdentity +); + +public record PatientResponse( + Guid Id, + string MaskedPersonalIdentityNumber, + string IdentityType, + bool IsIdentityConfirmed, + string FirstName, + string LastName, + DateOnly DateOfBirth, + string? Sex, + string? PhoneNumber, + string? Email, + bool HasProtectedIdentity +); diff --git a/src/Rinta.Desktop/Rinta.Desktop.csproj b/src/Rinta.Desktop/Rinta.Desktop.csproj new file mode 100644 index 0000000..70aa7bb --- /dev/null +++ b/src/Rinta.Desktop/Rinta.Desktop.csproj @@ -0,0 +1,19 @@ + + + WinExe + net8.0-windows + true + enable + enable + Assets\rinta-icon.ico + Rinta.Desktop + Rinta.Desktop + + + + PreserveNewest + + + + + diff --git a/src/Rinta.Desktop/Services/ApiClient.cs b/src/Rinta.Desktop/Services/ApiClient.cs new file mode 100644 index 0000000..0e271f4 --- /dev/null +++ b/src/Rinta.Desktop/Services/ApiClient.cs @@ -0,0 +1,286 @@ +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Rinta.Desktop.Models; + +namespace Rinta.Desktop.Services; + +public record LoginRequestBody(string UserName, string Password, string? ReturnUrl); + +public record ApiResult(bool Success, T? Value, string? ErrorMessage); + +///

+/// Ansvarar för all kommunikation med Rinta.Api. Använder en +/// CookieContainer så att inloggningscookien (satt av +/// AuthController.Login) automatiskt skickas med på alla efterföljande +/// anrop, precis som en webbläsare skulle göra - men helt utan webbläsare. +/// En instans av denna klass ska leva under hela klientapplikationens +/// körning (skapas i App.xaml.cs, delas mellan fönstren). +/// +public class ApiClient +{ + private readonly HttpClient _http; + + // Måste matcha AddJsonOptions i Rinta.Api/Program.cs (JsonStringEnumConverter) - + // annars misslyckas deserialisering av t.ex. ClinicalNoteType/PrescriptionStatus. + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() } + }; + + public ApiClient(string baseUrl) + { + var handler = new HttpClientHandler + { + CookieContainer = new CookieContainer(), + UseCookies = true + }; + _http = new HttpClient(handler) { BaseAddress = new Uri(baseUrl) }; + } + + public async Task> LoginAsync(string userName, string password) + { + try + { + var response = await _http.PostAsJsonAsync("api/auth/login", new LoginRequestBody(userName, password, null), JsonOptions); + if (response.IsSuccessStatusCode) + { + var user = await response.Content.ReadFromJsonAsync(JsonOptions); + return new ApiResult(true, user, null); + } + + var errorText = await response.Content.ReadAsStringAsync(); + return new ApiResult(false, null, string.IsNullOrWhiteSpace(errorText) + ? $"Inloggning misslyckades ({(int)response.StatusCode})." + : errorText); + } + catch (HttpRequestException ex) + { + return new ApiResult(false, null, $"Kunde inte nå servern: {ex.Message}"); + } + } + + public async Task LogoutAsync() + { + try { await _http.PostAsync("api/auth/logout", null); } + catch (HttpRequestException) { /* redan utloggad/servern nere - inget att göra åt det härifrån */ } + } + + public async Task>> SearchPatientsAsync(string lastName) + { + try + { + var response = await _http.GetAsync($"api/patients/search?lastName={Uri.EscapeDataString(lastName)}"); + if (!response.IsSuccessStatusCode) + return new ApiResult>(false, null, $"Sökningen misslyckades ({(int)response.StatusCode})."); + + var results = await response.Content.ReadFromJsonAsync>(JsonOptions) ?? new(); + return new ApiResult>(true, results, null); + } + catch (HttpRequestException ex) + { + return new ApiResult>(false, null, $"Kunde inte nå servern: {ex.Message}"); + } + } + + public async Task> CreatePatientAsync(CreatePatientRequest request) + { + try + { + var response = await _http.PostAsJsonAsync("api/patients", request, JsonOptions); + if (response.IsSuccessStatusCode) + { + var patient = await response.Content.ReadFromJsonAsync(JsonOptions); + return new ApiResult(true, patient, null); + } + + var errorText = await response.Content.ReadAsStringAsync(); + return new ApiResult(false, null, string.IsNullOrWhiteSpace(errorText) + ? $"Registreringen misslyckades ({(int)response.StatusCode})." + : errorText); + } + catch (HttpRequestException ex) + { + return new ApiResult(false, null, $"Kunde inte nå servern: {ex.Message}"); + } + } + + public async Task>> ListUsersAsync() + { + try + { + var response = await _http.GetAsync("api/users"); + if (!response.IsSuccessStatusCode) + return new ApiResult>(false, null, $"Kunde inte hämta användare ({(int)response.StatusCode})."); + + var users = await response.Content.ReadFromJsonAsync>(JsonOptions) ?? new(); + return new ApiResult>(true, users, null); + } + catch (HttpRequestException ex) + { + return new ApiResult>(false, null, $"Kunde inte nå servern: {ex.Message}"); + } + } + + public async Task> CreateUserAsync(CreateUserRequest request) + { + try + { + var response = await _http.PostAsJsonAsync("api/users", request, JsonOptions); + if (response.IsSuccessStatusCode) + { + var user = await response.Content.ReadFromJsonAsync(JsonOptions); + return new ApiResult(true, user, null); + } + + var errorText = await response.Content.ReadAsStringAsync(); + return new ApiResult(false, null, string.IsNullOrWhiteSpace(errorText) + ? $"Kunde inte skapa användaren ({(int)response.StatusCode})." + : errorText); + } + catch (HttpRequestException ex) + { + return new ApiResult(false, null, $"Kunde inte nå servern: {ex.Message}"); + } + } + + public async Task> DeactivateUserAsync(string userId) + { + try + { + var response = await _http.PostAsync($"api/users/{userId}/deactivate", null); + if (!response.IsSuccessStatusCode) + return new ApiResult(false, false, $"Kunde inte inaktivera användaren ({(int)response.StatusCode})."); + + return new ApiResult(true, true, null); + } + catch (HttpRequestException ex) + { + return new ApiResult(false, false, $"Kunde inte nå servern: {ex.Message}"); + } + } + + // --- Journalanteckningar --- + + public Task>> GetClinicalNotesAsync(Guid patientId) => + GetListAsync($"api/clinical-notes/by-patient/{patientId}", "Kunde inte hämta journalanteckningar"); + + public Task> CreateClinicalNoteAsync(CreateClinicalNoteRequest request) => + PostAsync("api/clinical-notes", request, "Kunde inte spara anteckningen"); + + public Task> SignClinicalNoteAsync(Guid noteId) => + PostNoContentAsync($"api/clinical-notes/{noteId}/sign", "Kunde inte signera anteckningen"); + + public Task> CorrectClinicalNoteAsync(CorrectClinicalNoteRequest request) => + PostAsync("api/clinical-notes/correct", request, "Kunde inte spara rättelsen"); + + // --- Recept/ordination --- + + public Task>> GetPrescriptionsAsync(Guid patientId) => + GetListAsync($"api/prescriptions/by-patient/{patientId}", "Kunde inte hämta ordinationer"); + + public Task> CreatePrescriptionAsync(CreatePrescriptionRequest request) => + PostAsync("api/prescriptions", request, "Kunde inte spara ordinationen"); + + public Task> SignPrescriptionAsync(Guid prescriptionId) => + PostNoContentAsync($"api/prescriptions/{prescriptionId}/sign", "Kunde inte signera ordinationen"); + + public Task> DiscontinuePrescriptionAsync(Guid prescriptionId, DiscontinuePrescriptionRequest request) => + PostNoContentWithBodyAsync(prescriptionId, request, id => $"api/prescriptions/{id}/discontinue", "Kunde inte sätta ut ordinationen"); + + public Task> ReplacePrescriptionAsync(Guid prescriptionId, ReplacePrescriptionRequest request) => + PostAsync($"api/prescriptions/{prescriptionId}/replace", request, "Kunde inte ersätta ordinationen"); + + // --- Läkemedelssök (openFDA - se README för begränsningar) --- + + public Task>> SearchMedicationsByDiagnosisAsync(string query) => + GetListAsync($"api/medications/search-by-diagnosis?query={Uri.EscapeDataString(query)}", "Sökningen misslyckades"); + + public Task>> SearchMedicationsByNameAsync(string query) => + GetListAsync($"api/medications/search-by-name?query={Uri.EscapeDataString(query)}", "Sökningen misslyckades"); + + // --- Generiska hjälpmetoder --- + + private async Task>> GetListAsync(string url, string errorPrefix) + { + try + { + var response = await _http.GetAsync(url); + if (!response.IsSuccessStatusCode) + return new ApiResult>(false, null, $"{errorPrefix} ({(int)response.StatusCode})."); + + var results = await response.Content.ReadFromJsonAsync>(JsonOptions) ?? new(); + return new ApiResult>(true, results, null); + } + catch (HttpRequestException ex) + { + return new ApiResult>(false, null, $"Kunde inte nå servern: {ex.Message}"); + } + } + + private async Task> PostAsync(string url, TRequest body, string errorPrefix) + { + try + { + var response = await _http.PostAsJsonAsync(url, body, JsonOptions); + if (response.IsSuccessStatusCode) + { + var value = await response.Content.ReadFromJsonAsync(JsonOptions); + return new ApiResult(true, value, null); + } + + var errorText = await response.Content.ReadAsStringAsync(); + return new ApiResult(false, default, string.IsNullOrWhiteSpace(errorText) + ? $"{errorPrefix} ({(int)response.StatusCode})." + : errorText); + } + catch (HttpRequestException ex) + { + return new ApiResult(false, default, $"Kunde inte nå servern: {ex.Message}"); + } + } + + private async Task> PostNoContentAsync(string url, string errorPrefix) + { + try + { + var response = await _http.PostAsync(url, null); + if (!response.IsSuccessStatusCode) + { + var errorText = await response.Content.ReadAsStringAsync(); + return new ApiResult(false, false, string.IsNullOrWhiteSpace(errorText) + ? $"{errorPrefix} ({(int)response.StatusCode})." + : errorText); + } + return new ApiResult(true, true, null); + } + catch (HttpRequestException ex) + { + return new ApiResult(false, false, $"Kunde inte nå servern: {ex.Message}"); + } + } + + private async Task> PostNoContentWithBodyAsync( + Guid id, TRequest body, Func urlBuilder, string errorPrefix) + { + try + { + var response = await _http.PostAsJsonAsync(urlBuilder(id), body, JsonOptions); + if (!response.IsSuccessStatusCode) + { + var errorText = await response.Content.ReadAsStringAsync(); + return new ApiResult(false, false, string.IsNullOrWhiteSpace(errorText) + ? $"{errorPrefix} ({(int)response.StatusCode})." + : errorText); + } + return new ApiResult(true, true, null); + } + catch (HttpRequestException ex) + { + return new ApiResult(false, false, $"Kunde inte nå servern: {ex.Message}"); + } + } +} diff --git a/src/Rinta.Desktop/Windows/AdminUsersWindow.xaml b/src/Rinta.Desktop/Windows/AdminUsersWindow.xaml new file mode 100644 index 0000000..96fbc05 --- /dev/null +++ b/src/Rinta.Desktop/Windows/AdminUsersWindow.xaml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +