Initial commit
This commit is contained in:
+31
@@ -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
|
||||
@@ -0,0 +1,222 @@
|
||||
<p align="center">
|
||||
<img src="src/Rinta.Desktop/Assets/rinta-logo.png" alt="Rinta" width="220" />
|
||||
</p>
|
||||
|
||||
# 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<ActionResult<AppointmentResponse>> 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<ActionResult<AppointmentResponse>> 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<ActionResult<List<AppointmentResponse>>> 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<ActionResult<List<AppointmentResponse>>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Räknar ut lediga bokningsbara luckor för en kliniker en given dag,
|
||||
/// baserat på ClinicianWorkingHours minus redan bokade (icke-avbokade) tider.
|
||||
/// </summary>
|
||||
[HttpGet("availability")]
|
||||
[Authorize(Roles = "Clinician,Receptionist,Admin")]
|
||||
public async Task<ActionResult<List<AvailableSlotResponse>>> 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<AvailableSlotResponse>());
|
||||
|
||||
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<AvailableSlotResponse>();
|
||||
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<ActionResult<AppointmentResponse>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<bool> 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
public AuthController(SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_signInManager = signInManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<UserResponse>> 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<IActionResult> Logout()
|
||||
{
|
||||
await _signInManager.SignOutAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<UserResponse>> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<ActionResult<List<ClinicalNoteResponse>>> 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<ActionResult<ClinicalNoteResponse>> 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<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("correct")]
|
||||
public async Task<ActionResult<ClinicalNoteResponse>> 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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å.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("search-by-diagnosis")]
|
||||
public async Task<ActionResult<List<MedicationSearchResponse>>> 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<ActionResult<List<MedicationSearchResponse>>> 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();
|
||||
}
|
||||
|
||||
/// <summary>Läkemedel som redan finns i den lokala cachen - fungerar utan extern åtkomst.</summary>
|
||||
[HttpGet("cached")]
|
||||
public async Task<ActionResult<List<MedicationSearchResponse>>> 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<MedicationLookupResult> 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);
|
||||
}
|
||||
@@ -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<ActionResult<PatientResponse>> 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<ActionResult<PatientResponse>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/confirm-identity")]
|
||||
[Authorize(Roles = "Clinician,Receptionist,Admin")]
|
||||
public async Task<ActionResult<PatientResponse>> 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<ActionResult<PatientResponse>> 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<ActionResult<List<PatientResponse>>> 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]}-****" : "****";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<ActionResult<List<PrescriptionResponse>>> 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<ActionResult<PrescriptionResponse>> 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<IActionResult> 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<IActionResult> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sätter ut en befintlig ordination och skapar en ny, länkad, med
|
||||
/// justerad dos/frekvens/väg. Ursprungsordinationen förblir orörd.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/replace")]
|
||||
public async Task<ActionResult<PrescriptionResponse>> 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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").
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
public class UsersController : ControllerBase
|
||||
{
|
||||
private static readonly string[] ValidRoles = { "Clinician", "Receptionist", "Admin" };
|
||||
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
|
||||
public UsersController(UserManager<ApplicationUser> userManager, ITenantProvider tenantProvider)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_tenantProvider = tenantProvider;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<UserResponse>>> List()
|
||||
{
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var users = await _userManager.Users.Where(u => u.TenantId == tenantId).ToListAsync();
|
||||
|
||||
var responses = new List<UserResponse>();
|
||||
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<ActionResult<UserResponse>> 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<string> { request.Role }));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/deactivate")]
|
||||
public async Task<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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<string> Roles
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
using Rinta.Domain.Common;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Rinta.Api.DTOs;
|
||||
|
||||
public record CreatePatientRequest(
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
);
|
||||
|
||||
/// <summary>Kopplar ett riktigt, verifierat personnummer till en patient som
|
||||
/// tidigare registrerats med reservnummer.</summary>
|
||||
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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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<ITenantProvider, HttpTenantProvider>();
|
||||
builder.Services.AddScoped<IIdentifierHasher, HmacIdentifierHasher>();
|
||||
builder.Services.AddScoped<AuditingSaveChangesInterceptor>();
|
||||
builder.Services.AddScoped<PatientService>();
|
||||
|
||||
builder.Services.AddDbContext<JournalDbContext>((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<AuditingSaveChangesInterceptor>());
|
||||
});
|
||||
|
||||
// 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<IMedicationLookupService, OpenFdaMedicationLookupService>(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<INavetLookupService, NavetLookupService>(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<ApplicationUser, IdentityRole>(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<JournalDbContext>()
|
||||
.AddClaimsPrincipalFactory<TenantClaimsPrincipalFactory>()
|
||||
.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();
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
static async Task SeedDevelopmentDataAsync(WebApplication app)
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JournalDbContext>();
|
||||
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<RoleManager<IdentityRole>>();
|
||||
foreach (var role in new[] { "Admin", "Clinician", "Receptionist" })
|
||||
{
|
||||
if (!await roleManager.RoleExistsAsync(role))
|
||||
await roleManager.CreateAsync(new IdentityRole(role));
|
||||
}
|
||||
|
||||
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UserSecretsId>journalsystem-api</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Rinta.Domain\Rinta.Domain.csproj" />
|
||||
<ProjectReference Include="..\Rinta.Infrastructure\Rinta.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<Application x:Class="Rinta.Desktop.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
|
||||
<!-- ============ FÄRGPALETT (matchar Rinta-loggan: marinblå + blå) ============ -->
|
||||
<SolidColorBrush x:Key="BrandDarkBrush" Color="#0A2F5C" />
|
||||
<SolidColorBrush x:Key="BrandAccentBrush" Color="#3FA9DC" />
|
||||
<SolidColorBrush x:Key="BackgroundBrush" Color="#F4F7FA" />
|
||||
<SolidColorBrush x:Key="SurfaceBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="BorderBrush" Color="#D6E1EA" />
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="#1F2937" />
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#5B6B7B" />
|
||||
<SolidColorBrush x:Key="ErrorBrush" Color="#B3261E" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#1E7B34" />
|
||||
<SolidColorBrush x:Key="WarningBackgroundBrush" Color="#FDECEA" />
|
||||
<SolidColorBrush x:Key="SuccessBackgroundBrush" Color="#E6F4EA" />
|
||||
|
||||
<!-- ============ FÖNSTER ============ -->
|
||||
<Style TargetType="Window">
|
||||
<Setter Property="FontFamily" Value="Segoe UI" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Background" Value="{StaticResource BackgroundBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ============ RUBRIKER / HJÄLPTEXTER ============ -->
|
||||
<Style x:Key="H1" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{StaticResource BrandDarkBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,12" />
|
||||
</Style>
|
||||
<Style x:Key="H2" TargetType="TextBlock">
|
||||
<Setter Property="FontSize" Value="15" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{StaticResource BrandDarkBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
</Style>
|
||||
<Style x:Key="Muted" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMutedBrush}" />
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
<Style x:Key="FieldLabel" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Margin" Value="0,0,0,3" />
|
||||
</Style>
|
||||
<Style x:Key="ErrorText" TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
|
||||
<!-- ============ KNAPPAR ============
|
||||
Implicit stil - gäller ALLA <Button> automatiskt.
|
||||
Tag="Secondary"/"Danger" för varianter. -->
|
||||
<Style TargetType="Button">
|
||||
<Setter Property="Background" Value="{StaticResource BrandDarkBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="12,6" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Height" Value="30" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border Background="{TemplateBinding Background}" CornerRadius="4" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" Margin="{TemplateBinding Padding}" />
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{StaticResource BrandAccentBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Opacity" Value="0.45" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Tag" Value="Secondary">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{StaticResource BrandDarkBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BrandDarkBrush}" />
|
||||
</Trigger>
|
||||
<Trigger Property="Tag" Value="Danger">
|
||||
<Setter Property="Background" Value="{StaticResource ErrorBrush}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ============ TEXTFÄLT ============ -->
|
||||
<Style TargetType="TextBox">
|
||||
<Setter Property="Padding" Value="6,5" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style TargetType="PasswordBox">
|
||||
<Setter Property="Padding" Value="6,5" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
|
||||
</Style>
|
||||
<Style TargetType="ComboBox">
|
||||
<Setter Property="Padding" Value="6,5" />
|
||||
<Setter Property="Height" Value="30" />
|
||||
</Style>
|
||||
|
||||
<!-- ============ DATAGRID ============ -->
|
||||
<Style TargetType="DataGrid">
|
||||
<Setter Property="Background" Value="{StaticResource SurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="RowHeight" Value="30" />
|
||||
<Setter Property="GridLinesVisibility" Value="Horizontal" />
|
||||
<Setter Property="HorizontalGridLinesBrush" Value="{StaticResource BorderBrush}" />
|
||||
<Setter Property="AlternatingRowBackground" Value="#FAFBFA" />
|
||||
<Setter Property="HeadersVisibility" Value="Column" />
|
||||
</Style>
|
||||
<Style TargetType="DataGridColumnHeader">
|
||||
<Setter Property="Background" Value="{StaticResource BackgroundBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextPrimaryBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="8,6" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
</Style>
|
||||
|
||||
<!-- ============ FLIKAR ============ -->
|
||||
<Style TargetType="TabItem">
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Delas av alla fönster under hela programmets körning -
|
||||
/// håller i CookieContainer:n som bär inloggningssessionen.</summary>
|
||||
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/";
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,97 @@
|
||||
<svg width="1000" height="960" viewBox="0 0 1000 960" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bookGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#0a2f5c"/>
|
||||
<stop offset="100%" stop-color="#123a6b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="shieldGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3fa9dc"/>
|
||||
<stop offset="100%" stop-color="#1f6fa8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="pageGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#bfe1f2"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<rect width="1000" height="960" fill="#ffffff"/>
|
||||
|
||||
<!-- Icon group -->
|
||||
<g transform="translate(350, 140)">
|
||||
|
||||
<!-- Fanned pages behind book -->
|
||||
<path d="M60 60
|
||||
C 140 10, 220 -10, 260 20
|
||||
C 230 60, 200 100, 195 150
|
||||
C 160 120, 110 100, 60 60 Z"
|
||||
fill="url(#pageGradient)" opacity="0.9"/>
|
||||
|
||||
<!-- Book left cover (dark navy) -->
|
||||
<path d="M0 90
|
||||
C 0 70, 15 55, 40 55
|
||||
L 150 55
|
||||
C 175 55, 195 75, 195 100
|
||||
L 195 400
|
||||
C 195 430, 170 460, 140 470
|
||||
L 20 470
|
||||
C 8 470, 0 460, 0 448
|
||||
Z"
|
||||
fill="url(#bookGradient)"/>
|
||||
|
||||
<!-- Page lines on left cover -->
|
||||
<g stroke="#ffffff" stroke-width="8" stroke-linecap="round" opacity="0.9">
|
||||
<line x1="35" y1="230" x2="100" y2="230"/>
|
||||
<line x1="35" y1="260" x2="100" y2="260"/>
|
||||
<line x1="35" y1="290" x2="100" y2="290"/>
|
||||
</g>
|
||||
|
||||
<!-- Shield / right side (book turning into shield shape) -->
|
||||
<path d="M195 100
|
||||
L195 400
|
||||
C 195 430, 220 460, 140 470
|
||||
L 260 470
|
||||
C 320 450, 370 410, 370 350
|
||||
L 370 140
|
||||
C 370 120, 355 105, 335 100
|
||||
L 195 100 Z"
|
||||
fill="url(#shieldGradient)"/>
|
||||
|
||||
<!-- Shield outline shape (cleaner) -->
|
||||
<path d="M205 110
|
||||
L335 110
|
||||
C 350 110, 362 122, 362 140
|
||||
L362 330
|
||||
C 362 380, 320 420, 270 445
|
||||
C 250 455, 225 462, 205 466
|
||||
Z"
|
||||
fill="url(#shieldGradient)"/>
|
||||
|
||||
<!-- White cross on shield -->
|
||||
<g fill="#ffffff">
|
||||
<rect x="255" y="195" width="40" height="130" rx="6"/>
|
||||
<rect x="215" y="235" width="120" height="40" rx="6"/>
|
||||
</g>
|
||||
|
||||
<!-- Spine highlight -->
|
||||
<path d="M195 100 L195 460" stroke="#ffffff" stroke-width="4" opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- Wordmark -->
|
||||
<g font-family="Arial, Helvetica, sans-serif" font-weight="700">
|
||||
<text x="500" y="740" font-size="150" fill="#0a2f5c" text-anchor="middle" letter-spacing="-2">
|
||||
R<tspan fill="#0a2f5c">i</tspan>nta
|
||||
</text>
|
||||
<!-- dot over i replaced with blue circle -->
|
||||
<circle cx="495" cy="595" r="12" fill="#3fa9dc"/>
|
||||
</g>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<g font-family="Arial, Helvetica, sans-serif" font-weight="600" font-size="34" letter-spacing="6" fill="#5b6b7b" text-anchor="middle">
|
||||
<text x="500" y="820">JOURNALSYSTEM</text>
|
||||
</g>
|
||||
|
||||
<!-- Decorative lines beside subtitle -->
|
||||
<line x1="255" y1="808" x2="320" y2="808" stroke="#3fa9dc" stroke-width="4"/>
|
||||
<line x1="680" y1="808" x2="745" y2="808" stroke="#3fa9dc" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1,132 @@
|
||||
namespace Rinta.Desktop.Models;
|
||||
|
||||
public record UserInfo(string Id, string UserName, string DisplayName, string Email, bool IsActive, List<string> 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
|
||||
);
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWPF>true</UseWPF>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<ApplicationIcon>Assets\rinta-icon.ico</ApplicationIcon>
|
||||
<AssemblyName>Rinta.Desktop</AssemblyName>
|
||||
<RootNamespace>Rinta.Desktop</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Resource Include="Assets\rinta-logo.png" />
|
||||
<Resource Include="Assets\rinta-icon.ico" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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<T>(bool Success, T? Value, string? ErrorMessage);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<ApiResult<UserInfo>> 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<UserInfo>(JsonOptions);
|
||||
return new ApiResult<UserInfo>(true, user, null);
|
||||
}
|
||||
|
||||
var errorText = await response.Content.ReadAsStringAsync();
|
||||
return new ApiResult<UserInfo>(false, null, string.IsNullOrWhiteSpace(errorText)
|
||||
? $"Inloggning misslyckades ({(int)response.StatusCode})."
|
||||
: errorText);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<UserInfo>(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<ApiResult<List<PatientResponse>>> SearchPatientsAsync(string lastName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync($"api/patients/search?lastName={Uri.EscapeDataString(lastName)}");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new ApiResult<List<PatientResponse>>(false, null, $"Sökningen misslyckades ({(int)response.StatusCode}).");
|
||||
|
||||
var results = await response.Content.ReadFromJsonAsync<List<PatientResponse>>(JsonOptions) ?? new();
|
||||
return new ApiResult<List<PatientResponse>>(true, results, null);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<List<PatientResponse>>(false, null, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ApiResult<PatientResponse>> CreatePatientAsync(CreatePatientRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/patients", request, JsonOptions);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var patient = await response.Content.ReadFromJsonAsync<PatientResponse>(JsonOptions);
|
||||
return new ApiResult<PatientResponse>(true, patient, null);
|
||||
}
|
||||
|
||||
var errorText = await response.Content.ReadAsStringAsync();
|
||||
return new ApiResult<PatientResponse>(false, null, string.IsNullOrWhiteSpace(errorText)
|
||||
? $"Registreringen misslyckades ({(int)response.StatusCode})."
|
||||
: errorText);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<PatientResponse>(false, null, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ApiResult<List<UserInfo>>> ListUsersAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync("api/users");
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new ApiResult<List<UserInfo>>(false, null, $"Kunde inte hämta användare ({(int)response.StatusCode}).");
|
||||
|
||||
var users = await response.Content.ReadFromJsonAsync<List<UserInfo>>(JsonOptions) ?? new();
|
||||
return new ApiResult<List<UserInfo>>(true, users, null);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<List<UserInfo>>(false, null, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ApiResult<UserInfo>> CreateUserAsync(CreateUserRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/users", request, JsonOptions);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var user = await response.Content.ReadFromJsonAsync<UserInfo>(JsonOptions);
|
||||
return new ApiResult<UserInfo>(true, user, null);
|
||||
}
|
||||
|
||||
var errorText = await response.Content.ReadAsStringAsync();
|
||||
return new ApiResult<UserInfo>(false, null, string.IsNullOrWhiteSpace(errorText)
|
||||
? $"Kunde inte skapa användaren ({(int)response.StatusCode})."
|
||||
: errorText);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<UserInfo>(false, null, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ApiResult<bool>> DeactivateUserAsync(string userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.PostAsync($"api/users/{userId}/deactivate", null);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new ApiResult<bool>(false, false, $"Kunde inte inaktivera användaren ({(int)response.StatusCode}).");
|
||||
|
||||
return new ApiResult<bool>(true, true, null);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<bool>(false, false, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Journalanteckningar ---
|
||||
|
||||
public Task<ApiResult<List<ClinicalNoteResponse>>> GetClinicalNotesAsync(Guid patientId) =>
|
||||
GetListAsync<ClinicalNoteResponse>($"api/clinical-notes/by-patient/{patientId}", "Kunde inte hämta journalanteckningar");
|
||||
|
||||
public Task<ApiResult<ClinicalNoteResponse>> CreateClinicalNoteAsync(CreateClinicalNoteRequest request) =>
|
||||
PostAsync<CreateClinicalNoteRequest, ClinicalNoteResponse>("api/clinical-notes", request, "Kunde inte spara anteckningen");
|
||||
|
||||
public Task<ApiResult<bool>> SignClinicalNoteAsync(Guid noteId) =>
|
||||
PostNoContentAsync($"api/clinical-notes/{noteId}/sign", "Kunde inte signera anteckningen");
|
||||
|
||||
public Task<ApiResult<ClinicalNoteResponse>> CorrectClinicalNoteAsync(CorrectClinicalNoteRequest request) =>
|
||||
PostAsync<CorrectClinicalNoteRequest, ClinicalNoteResponse>("api/clinical-notes/correct", request, "Kunde inte spara rättelsen");
|
||||
|
||||
// --- Recept/ordination ---
|
||||
|
||||
public Task<ApiResult<List<PrescriptionResponse>>> GetPrescriptionsAsync(Guid patientId) =>
|
||||
GetListAsync<PrescriptionResponse>($"api/prescriptions/by-patient/{patientId}", "Kunde inte hämta ordinationer");
|
||||
|
||||
public Task<ApiResult<PrescriptionResponse>> CreatePrescriptionAsync(CreatePrescriptionRequest request) =>
|
||||
PostAsync<CreatePrescriptionRequest, PrescriptionResponse>("api/prescriptions", request, "Kunde inte spara ordinationen");
|
||||
|
||||
public Task<ApiResult<bool>> SignPrescriptionAsync(Guid prescriptionId) =>
|
||||
PostNoContentAsync($"api/prescriptions/{prescriptionId}/sign", "Kunde inte signera ordinationen");
|
||||
|
||||
public Task<ApiResult<bool>> DiscontinuePrescriptionAsync(Guid prescriptionId, DiscontinuePrescriptionRequest request) =>
|
||||
PostNoContentWithBodyAsync(prescriptionId, request, id => $"api/prescriptions/{id}/discontinue", "Kunde inte sätta ut ordinationen");
|
||||
|
||||
public Task<ApiResult<PrescriptionResponse>> ReplacePrescriptionAsync(Guid prescriptionId, ReplacePrescriptionRequest request) =>
|
||||
PostAsync<ReplacePrescriptionRequest, PrescriptionResponse>($"api/prescriptions/{prescriptionId}/replace", request, "Kunde inte ersätta ordinationen");
|
||||
|
||||
// --- Läkemedelssök (openFDA - se README för begränsningar) ---
|
||||
|
||||
public Task<ApiResult<List<MedicationSearchResponse>>> SearchMedicationsByDiagnosisAsync(string query) =>
|
||||
GetListAsync<MedicationSearchResponse>($"api/medications/search-by-diagnosis?query={Uri.EscapeDataString(query)}", "Sökningen misslyckades");
|
||||
|
||||
public Task<ApiResult<List<MedicationSearchResponse>>> SearchMedicationsByNameAsync(string query) =>
|
||||
GetListAsync<MedicationSearchResponse>($"api/medications/search-by-name?query={Uri.EscapeDataString(query)}", "Sökningen misslyckades");
|
||||
|
||||
// --- Generiska hjälpmetoder ---
|
||||
|
||||
private async Task<ApiResult<List<T>>> GetListAsync<T>(string url, string errorPrefix)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new ApiResult<List<T>>(false, null, $"{errorPrefix} ({(int)response.StatusCode}).");
|
||||
|
||||
var results = await response.Content.ReadFromJsonAsync<List<T>>(JsonOptions) ?? new();
|
||||
return new ApiResult<List<T>>(true, results, null);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<List<T>>(false, null, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApiResult<TResponse>> PostAsync<TRequest, TResponse>(string url, TRequest body, string errorPrefix)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync(url, body, JsonOptions);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var value = await response.Content.ReadFromJsonAsync<TResponse>(JsonOptions);
|
||||
return new ApiResult<TResponse>(true, value, null);
|
||||
}
|
||||
|
||||
var errorText = await response.Content.ReadAsStringAsync();
|
||||
return new ApiResult<TResponse>(false, default, string.IsNullOrWhiteSpace(errorText)
|
||||
? $"{errorPrefix} ({(int)response.StatusCode})."
|
||||
: errorText);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<TResponse>(false, default, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApiResult<bool>> 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<bool>(false, false, string.IsNullOrWhiteSpace(errorText)
|
||||
? $"{errorPrefix} ({(int)response.StatusCode})."
|
||||
: errorText);
|
||||
}
|
||||
return new ApiResult<bool>(true, true, null);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<bool>(false, false, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApiResult<bool>> PostNoContentWithBodyAsync<TRequest>(
|
||||
Guid id, TRequest body, Func<Guid, string> 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<bool>(false, false, string.IsNullOrWhiteSpace(errorText)
|
||||
? $"{errorPrefix} ({(int)response.StatusCode})."
|
||||
: errorText);
|
||||
}
|
||||
return new ApiResult<bool>(true, true, null);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
return new ApiResult<bool>(false, false, $"Kunde inte nå servern: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<Window x:Class="Rinta.Desktop.Windows.AdminUsersWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Hantera användare" Icon="Assets/rinta-icon.ico" Height="560" Width="760" WindowStartupLocation="CenterOwner">
|
||||
<Grid Margin="16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="280" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Grid Grid.Column="0" Margin="0,0,16,0">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Befintliga användare" FontSize="16" FontWeight="Bold" Margin="0,0,0,8" />
|
||||
|
||||
<DataGrid Grid.Row="1" x:Name="UsersGrid" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
CanUserAddRows="False" SelectionChanged="UsersGrid_SelectionChanged">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Användarnamn" Binding="{Binding UserName}" Width="120" />
|
||||
<DataGridTextColumn Header="Namn" Binding="{Binding DisplayName}" Width="140" />
|
||||
<DataGridTextColumn Header="E-post" Binding="{Binding Email}" Width="160" />
|
||||
<DataGridTextColumn Header="Roller" Binding="{Binding RolesDisplay}" Width="120" />
|
||||
<DataGridTextColumn Header="Aktiv" Binding="{Binding IsActive}" Width="60" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Column="1">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Skapa ny användare" FontSize="16" FontWeight="Bold" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Användarnamn *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="2" x:Name="NewUserNameBox" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Namn *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="4" x:Name="NewDisplayNameBox" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="5" Text="E-post *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="6" x:Name="NewEmailBox" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="7" Text="Tillfälligt lösenord *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="8" x:Name="NewPasswordBox" Margin="0,0,0,2" />
|
||||
<TextBlock Grid.Row="9" Text="Minst 12 tecken, versal+gemen+siffra+specialtecken. Kommunicera lösenordet till personen manuellt." FontSize="10" Foreground="Gray" TextWrapping="Wrap" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="10" Text="Roll *" Margin="0,0,0,2" />
|
||||
<ComboBox Grid.Row="11" x:Name="RoleComboBox" Margin="0,0,0,12" SelectedIndex="0">
|
||||
<ComboBoxItem Content="Clinician" ToolTip="Får skriva journalanteckningar och ordinera läkemedel" />
|
||||
<ComboBoxItem Content="Receptionist" ToolTip="Bokning och patientregistrering, inte journal/ordination" />
|
||||
<ComboBoxItem Content="Admin" ToolTip="Kan skapa/hantera användarkonton" />
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Grid.Row="12" x:Name="ErrorText" Foreground="Red" TextWrapping="Wrap" VerticalAlignment="Top" />
|
||||
|
||||
<StackPanel Grid.Row="13">
|
||||
<Button x:Name="CreateButton" Content="Skapa användare" Height="30" Margin="0,0,0,8" Click="CreateButton_Click" />
|
||||
<Button x:Name="DeactivateButton" Content="Inaktivera vald användare" Height="30" IsEnabled="False" Click="DeactivateButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Windows;
|
||||
using Rinta.Desktop.Models;
|
||||
|
||||
namespace Rinta.Desktop.Windows;
|
||||
|
||||
/// <summary>Radvy för DataGrid - lägger till RolesDisplay eftersom en
|
||||
/// DataGridTextColumn inte kan rendera en List<string> direkt.</summary>
|
||||
public class UserRow
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string UserName { get; init; }
|
||||
public required string DisplayName { get; init; }
|
||||
public required string Email { get; init; }
|
||||
public required bool IsActive { get; init; }
|
||||
public required string RolesDisplay { get; init; }
|
||||
|
||||
public static UserRow From(UserInfo user) => new()
|
||||
{
|
||||
Id = user.Id,
|
||||
UserName = user.UserName,
|
||||
DisplayName = user.DisplayName,
|
||||
Email = user.Email,
|
||||
IsActive = user.IsActive,
|
||||
RolesDisplay = string.Join(", ", user.Roles)
|
||||
};
|
||||
}
|
||||
|
||||
public partial class AdminUsersWindow : Window
|
||||
{
|
||||
private UserRow? _selectedUser;
|
||||
|
||||
public AdminUsersWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
_ = LoadUsersAsync();
|
||||
}
|
||||
|
||||
private async Task LoadUsersAsync()
|
||||
{
|
||||
var result = await App.ApiClient.ListUsersAsync();
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
ShowError(result.ErrorMessage ?? "Kunde inte hämta användare.");
|
||||
return;
|
||||
}
|
||||
|
||||
UsersGrid.ItemsSource = result.Value.Select(UserRow.From).ToList();
|
||||
}
|
||||
|
||||
private void UsersGrid_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||
{
|
||||
_selectedUser = UsersGrid.SelectedItem as UserRow;
|
||||
DeactivateButton.IsEnabled = _selectedUser is not null && _selectedUser.IsActive;
|
||||
}
|
||||
|
||||
private async void CreateButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ClearError();
|
||||
|
||||
var userName = NewUserNameBox.Text.Trim();
|
||||
var displayName = NewDisplayNameBox.Text.Trim();
|
||||
var email = NewEmailBox.Text.Trim();
|
||||
var password = NewPasswordBox.Text;
|
||||
var role = (RoleComboBox.SelectedItem as System.Windows.Controls.ComboBoxItem)?.Content?.ToString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(displayName)
|
||||
|| string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password) || role is null)
|
||||
{
|
||||
ShowError("Alla fält markerade * krävs.");
|
||||
return;
|
||||
}
|
||||
|
||||
CreateButton.IsEnabled = false;
|
||||
|
||||
var result = await App.ApiClient.CreateUserAsync(new CreateUserRequest(userName, email, displayName, password, role));
|
||||
|
||||
CreateButton.IsEnabled = true;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
ShowError(result.ErrorMessage ?? "Kunde inte skapa användaren.");
|
||||
return;
|
||||
}
|
||||
|
||||
NewUserNameBox.Clear();
|
||||
NewDisplayNameBox.Clear();
|
||||
NewEmailBox.Clear();
|
||||
NewPasswordBox.Clear();
|
||||
await LoadUsersAsync();
|
||||
}
|
||||
|
||||
private async void DeactivateButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedUser is null) return;
|
||||
|
||||
var confirm = MessageBox.Show(
|
||||
$"Inaktivera {_selectedUser.DisplayName} ({_selectedUser.UserName})? Kontot kan inte logga in längre, men historiken (vem skrev/signerade vad) bevaras.",
|
||||
"Bekräfta inaktivering", MessageBoxButton.YesNo, MessageBoxImage.Warning);
|
||||
if (confirm != MessageBoxResult.Yes) return;
|
||||
|
||||
var result = await App.ApiClient.DeactivateUserAsync(_selectedUser.Id);
|
||||
if (!result.Success)
|
||||
{
|
||||
ShowError(result.ErrorMessage ?? "Kunde inte inaktivera användaren.");
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadUsersAsync();
|
||||
}
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
ErrorText.Text = message;
|
||||
}
|
||||
|
||||
private void ClearError()
|
||||
{
|
||||
ErrorText.Text = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Window x:Class="Rinta.Desktop.Windows.CorrectNoteDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Rätta anteckning" Icon="Assets/rinta-icon.ico" Height="400" Width="440" WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="En rättelse skapar en NY anteckning länkad till originalet - originalet ändras eller raderas aldrig (Patientdatalagen 3 kap 8§)."
|
||||
TextWrapping="Wrap" FontSize="11" Foreground="Gray" Margin="0,0,0,12" />
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Rättad text" Margin="0,0,0,4" />
|
||||
<TextBox Grid.Row="2" x:Name="ContentBox" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Anledning till rättelse" Margin="0,0,0,4" />
|
||||
<TextBox Grid.Row="4" x:Name="ReasonBox" Margin="0,0,0,8" />
|
||||
|
||||
<StackPanel Grid.Row="5" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Avbryt" Width="80" Margin="0,0,8,0" Click="CancelButton_Click" />
|
||||
<Button Content="Spara rättelse" Width="110" Click="SaveButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Rinta.Desktop.Windows;
|
||||
|
||||
public partial class CorrectNoteDialog : Window
|
||||
{
|
||||
public string CorrectedContent => ContentBox.Text.Trim();
|
||||
public string Reason => ReasonBox.Text.Trim();
|
||||
|
||||
public CorrectNoteDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void SaveButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ContentBox.Text) || string.IsNullOrWhiteSpace(ReasonBox.Text))
|
||||
{
|
||||
MessageBox.Show("Både rättad text och anledning krävs.", "Saknas", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Window x:Class="Rinta.Desktop.Windows.LoginWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Rinta — Logga in" Height="360" Width="380" Icon="Assets/rinta-icon.ico"
|
||||
WindowStartupLocation="CenterScreen" ResizeMode="NoResize">
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Image Grid.Row="0" Source="Assets/rinta-logo.png" Height="90" Margin="0,0,0,16" />
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Användarnamn" Style="{StaticResource FieldLabel}" />
|
||||
<TextBox Grid.Row="2" x:Name="UserNameBox" Margin="0,0,0,12" />
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Lösenord" Style="{StaticResource FieldLabel}" />
|
||||
<PasswordBox Grid.Row="4" x:Name="PasswordBox" Margin="0,0,0,12" KeyDown="PasswordBox_KeyDown" />
|
||||
|
||||
<TextBlock Grid.Row="5" x:Name="ErrorText" Style="{StaticResource ErrorText}" Margin="0,0,0,8" Visibility="Collapsed" />
|
||||
|
||||
<StackPanel Grid.Row="6" VerticalAlignment="Bottom">
|
||||
<Button x:Name="LoginButton" Content="Logga in" Height="34" Click="LoginButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace Rinta.Desktop.Windows;
|
||||
|
||||
public partial class LoginWindow : Window
|
||||
{
|
||||
public LoginWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void LoginButton_Click(object sender, RoutedEventArgs e) => await AttemptLoginAsync();
|
||||
|
||||
private async void PasswordBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter) await AttemptLoginAsync();
|
||||
}
|
||||
|
||||
private async Task AttemptLoginAsync()
|
||||
{
|
||||
ErrorText.Visibility = Visibility.Collapsed;
|
||||
LoginButton.IsEnabled = false;
|
||||
|
||||
var result = await App.ApiClient.LoginAsync(UserNameBox.Text.Trim(), PasswordBox.Password);
|
||||
|
||||
LoginButton.IsEnabled = true;
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
ErrorText.Text = result.ErrorMessage ?? "Inloggningen misslyckades.";
|
||||
ErrorText.Visibility = Visibility.Visible;
|
||||
return;
|
||||
}
|
||||
|
||||
var mainWindow = new MainWindow(result.Value);
|
||||
mainWindow.Show();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<Window x:Class="Rinta.Desktop.Windows.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Rinta" Icon="Assets/rinta-icon.ico" Height="520" Width="800" WindowStartupLocation="CenterScreen">
|
||||
<Grid Margin="16">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<DockPanel Grid.Row="0" Margin="0,0,0,12">
|
||||
<TextBlock x:Name="WelcomeText" FontSize="16" FontWeight="Bold" DockPanel.Dock="Left" />
|
||||
<Button x:Name="LogoutButton" Content="Logga ut" Width="90" HorizontalAlignment="Right" Click="LogoutButton_Click" />
|
||||
<Button x:Name="AdminButton" Content="Hantera användare" Width="130" HorizontalAlignment="Right" Margin="0,0,8,0" Click="AdminButton_Click" Visibility="Collapsed" />
|
||||
</DockPanel>
|
||||
|
||||
<DockPanel Grid.Row="1" Margin="0,0,0,12">
|
||||
<Button x:Name="RegisterButton" Content="Registrera patient" Width="150" DockPanel.Dock="Right" Margin="8,0,0,0" Click="RegisterButton_Click" />
|
||||
<Button x:Name="SearchButton" Content="Sök" Width="80" DockPanel.Dock="Right" Margin="8,0,0,0" Click="SearchButton_Click" />
|
||||
<TextBlock Text="Efternamn:" VerticalAlignment="Center" Margin="0,0,8,0" DockPanel.Dock="Left" />
|
||||
<TextBox x:Name="LastNameBox" VerticalAlignment="Center" KeyDown="LastNameBox_KeyDown" />
|
||||
</DockPanel>
|
||||
|
||||
<DataGrid Grid.Row="2" x:Name="ResultsGrid" AutoGenerateColumns="False" IsReadOnly="True" CanUserAddRows="False"
|
||||
MouseDoubleClick="ResultsGrid_MouseDoubleClick">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Förnamn" Binding="{Binding FirstName}" Width="120" />
|
||||
<DataGridTextColumn Header="Efternamn" Binding="{Binding LastName}" Width="120" />
|
||||
<DataGridTextColumn Header="Identifierare" Binding="{Binding MaskedPersonalIdentityNumber}" Width="140" />
|
||||
<DataGridTextColumn Header="Typ" Binding="{Binding IdentityType}" Width="140" />
|
||||
<DataGridTextColumn Header="Bekräftad" Binding="{Binding IsIdentityConfirmed}" Width="90" />
|
||||
<DataGridTextColumn Header="Född" Binding="{Binding DateOfBirth}" Width="110" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<TextBlock Grid.Row="3" x:Name="StatusText" Margin="0,8,0,0" Foreground="Gray" />
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using Rinta.Desktop.Models;
|
||||
|
||||
namespace Rinta.Desktop.Windows;
|
||||
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
private readonly UserInfo _currentUser;
|
||||
|
||||
public MainWindow(UserInfo currentUser)
|
||||
{
|
||||
InitializeComponent();
|
||||
_currentUser = currentUser;
|
||||
WelcomeText.Text = $"Inloggad som {currentUser.DisplayName} ({string.Join(", ", currentUser.Roles)})";
|
||||
|
||||
if (currentUser.Roles.Contains("Admin"))
|
||||
AdminButton.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
private void AdminButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var adminWindow = new AdminUsersWindow { Owner = this };
|
||||
adminWindow.ShowDialog();
|
||||
}
|
||||
|
||||
private async void SearchButton_Click(object sender, RoutedEventArgs e) => await SearchAsync();
|
||||
|
||||
private async void LastNameBox_KeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter) await SearchAsync();
|
||||
}
|
||||
|
||||
private async Task SearchAsync()
|
||||
{
|
||||
StatusText.Text = "Söker...";
|
||||
var result = await App.ApiClient.SearchPatientsAsync(LastNameBox.Text.Trim());
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
StatusText.Text = result.ErrorMessage ?? "Sökningen misslyckades.";
|
||||
ResultsGrid.ItemsSource = null;
|
||||
return;
|
||||
}
|
||||
|
||||
ResultsGrid.ItemsSource = result.Value;
|
||||
StatusText.Text = $"{result.Value.Count} träff(ar).";
|
||||
}
|
||||
|
||||
private void RegisterButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var registerWindow = new PatientRegisterWindow { Owner = this };
|
||||
if (registerWindow.ShowDialog() == true)
|
||||
{
|
||||
// Ny patient registrerad - kör sökningen igen så listan uppdateras.
|
||||
_ = SearchAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void ResultsGrid_MouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (ResultsGrid.SelectedItem is not PatientResponse patient) return;
|
||||
|
||||
if (!_currentUser.Roles.Contains("Clinician"))
|
||||
{
|
||||
MessageBox.Show("Bara konton med rollen Clinician kan öppna journalen.", "Behörighet saknas",
|
||||
MessageBoxButton.OK, MessageBoxImage.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var journalWindow = new PatientJournalWindow(patient.Id, $"{patient.FirstName} {patient.LastName}") { Owner = this };
|
||||
journalWindow.ShowDialog();
|
||||
}
|
||||
|
||||
private async void LogoutButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await App.ApiClient.LogoutAsync();
|
||||
var loginWindow = new LoginWindow();
|
||||
loginWindow.Show();
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<Window x:Class="Rinta.Desktop.Windows.PatientJournalWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Journal" Icon="Assets/rinta-icon.ico" Height="700" Width="960" WindowStartupLocation="CenterOwner">
|
||||
<Grid Margin="12">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" x:Name="PatientHeaderText" FontSize="16" FontWeight="Bold" Margin="0,0,0,10" />
|
||||
|
||||
<TabControl Grid.Row="1">
|
||||
<!-- ===================== JOURNALANTECKNINGAR ===================== -->
|
||||
<TabItem Header="Journalanteckningar">
|
||||
<Grid Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="2*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="1.2*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<DataGrid Grid.Row="0" x:Name="NotesGrid" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
CanUserAddRows="False" SelectionChanged="NotesGrid_SelectionChanged">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Typ" Binding="{Binding NoteType}" Width="120" />
|
||||
<DataGridTextColumn Header="Innehåll" Binding="{Binding ContentPreview}" Width="*" />
|
||||
<DataGridTextColumn Header="Ansvarig" Binding="{Binding ResponsibleClinicianRole}" Width="100" />
|
||||
<DataGridTextColumn Header="Signerad" Binding="{Binding IsSigned}" Width="70" />
|
||||
<DataGridTextColumn Header="Skapad" Binding="{Binding CreatedAtUtc}" Width="140" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,8,0,8">
|
||||
<Button x:Name="SignNoteButton" Content="Signera vald anteckning" Width="160" Margin="0,0,8,0" IsEnabled="False" Click="SignNoteButton_Click" />
|
||||
<Button x:Name="CorrectNoteButton" Content="Rätta vald anteckning" Width="150" IsEnabled="False" Click="CorrectNoteButton_Click" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="160" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="160" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Text="Ny anteckning" FontWeight="Bold" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Typ" VerticalAlignment="Center" />
|
||||
<ComboBox Grid.Row="1" Grid.Column="1" x:Name="NoteTypeCombo" HorizontalAlignment="Left" Width="180" SelectedIndex="0" Margin="0,0,0,6">
|
||||
<ComboBoxItem Content="Anamnes" />
|
||||
<ComboBoxItem Content="StatusPresens" />
|
||||
<ComboBoxItem Content="Bedomning" />
|
||||
<ComboBoxItem Content="Atgardsplan" />
|
||||
<ComboBoxItem Content="Epikris" />
|
||||
<ComboBoxItem Content="TelefonRadgivning" />
|
||||
<ComboBoxItem Content="Ovrigt" />
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="2" Text="Din yrkesroll" VerticalAlignment="Center" />
|
||||
|
||||
<TextBox Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2" x:Name="NoteContentBox"
|
||||
AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" Margin="0,0,8,0" />
|
||||
<TextBox Grid.Row="2" Grid.Column="2" x:Name="NoteClinicianRoleBox" VerticalAlignment="Top" Text="Läkare" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.ColumnSpan="3" x:Name="NoteErrorText" Foreground="Red" TextWrapping="Wrap" Margin="0,6,0,0" />
|
||||
|
||||
<Button Grid.Row="4" Grid.Column="2" x:Name="SaveNoteButton" Content="Spara anteckning" Margin="0,8,0,0" Click="SaveNoteButton_Click" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
|
||||
<!-- ===================== RECEPT / ORDINATION ===================== -->
|
||||
<TabItem Header="Recept / Ordination">
|
||||
<Grid Margin="8">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="1.6*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="1.6*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<DataGrid Grid.Row="0" x:Name="PrescriptionsGrid" AutoGenerateColumns="False" IsReadOnly="True"
|
||||
CanUserAddRows="False" SelectionChanged="PrescriptionsGrid_SelectionChanged">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="Läkemedel" Binding="{Binding DrugName}" Width="160" />
|
||||
<DataGridTextColumn Header="Dos" Binding="{Binding Dosage}" Width="100" />
|
||||
<DataGridTextColumn Header="Frekvens" Binding="{Binding Frequency}" Width="140" />
|
||||
<DataGridTextColumn Header="Status" Binding="{Binding Status}" Width="90" />
|
||||
<DataGridTextColumn Header="Signerad" Binding="{Binding IsSigned}" Width="70" />
|
||||
<DataGridTextColumn Header="Startdatum" Binding="{Binding StartDate}" Width="100" />
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,8,0,8">
|
||||
<Button x:Name="SignPrescriptionButton" Content="Signera vald" Width="110" Margin="0,0,8,0" IsEnabled="False" Click="SignPrescriptionButton_Click" />
|
||||
<Button x:Name="DiscontinueButton" Content="Sätt ut vald" Width="110" Margin="0,0,8,0" IsEnabled="False" Click="DiscontinueButton_Click" />
|
||||
<Button x:Name="ReplaceButton" Content="Ändra dos..." Width="110" IsEnabled="False" Click="ReplaceButton_Click" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.ColumnSpan="2" Text="Ny ordination" FontWeight="Bold" Margin="0,0,0,6" />
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Text="Sök läkemedel (namn eller diagnos/tillstånd)" Margin="0,0,8,2" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Text="Träffar (klicka för att fylla i namn)" Margin="0,0,0,2" />
|
||||
|
||||
<DockPanel Grid.Row="2" Grid.Column="0" Margin="0,0,8,8">
|
||||
<Button DockPanel.Dock="Right" Content="Sök" Width="60" Click="SearchMedicationButton_Click" />
|
||||
<TextBox x:Name="MedicationSearchBox" />
|
||||
</DockPanel>
|
||||
<ListBox Grid.Row="2" Grid.Column="1" x:Name="MedicationResultsList" Height="60" Margin="0,0,0,8"
|
||||
DisplayMemberPath="DisplayText" MouseDoubleClick="MedicationResultsList_MouseDoubleClick" />
|
||||
|
||||
<TextBlock Grid.Row="3" Grid.Column="0" Text="Läkemedel *" Margin="0,0,8,2" />
|
||||
<TextBlock Grid.Row="3" Grid.Column="1" Text="ATC-kod (valfritt)" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="4" Grid.Column="0" x:Name="DrugNameBox" Margin="0,0,8,8" />
|
||||
<TextBox Grid.Row="4" Grid.Column="1" x:Name="AtcCodeBox" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="5" Grid.Column="0" Text="Dos *" Margin="0,0,8,2" />
|
||||
<TextBlock Grid.Row="5" Grid.Column="1" Text="Frekvens *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="6" Grid.Column="0" x:Name="DosageBox" Margin="0,0,8,8" />
|
||||
<TextBox Grid.Row="6" Grid.Column="1" x:Name="FrequencyBox" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="7" Grid.Column="0" Text="Diagnoskod (ICD-10, valfritt)" Margin="0,0,8,2" />
|
||||
<TextBlock Grid.Row="7" Grid.Column="1" Text="Diagnosbeskrivning (valfritt)" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="8" Grid.Column="0" x:Name="DiagnosisCodeBox" Margin="0,0,8,8" VerticalAlignment="Top" />
|
||||
<TextBox Grid.Row="8" Grid.Column="1" x:Name="DiagnosisDescriptionBox" Margin="0,0,0,8" VerticalAlignment="Top" />
|
||||
|
||||
<TextBlock Grid.Row="9" Grid.Column="0" x:Name="PrescriptionErrorText" Foreground="Red" TextWrapping="Wrap" Grid.ColumnSpan="2" />
|
||||
<Button Grid.Row="9" Grid.Column="1" x:Name="SavePrescriptionButton" Content="Spara ordination"
|
||||
HorizontalAlignment="Right" Width="140" Click="SavePrescriptionButton_Click" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using Rinta.Desktop.Models;
|
||||
|
||||
namespace Rinta.Desktop.Windows;
|
||||
|
||||
public class NoteRow
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public required string NoteType { get; init; }
|
||||
public required string ContentPreview { get; init; }
|
||||
public required string ResponsibleClinicianRole { get; init; }
|
||||
public required bool IsSigned { get; init; }
|
||||
public required DateTimeOffset CreatedAtUtc { get; init; }
|
||||
|
||||
public static NoteRow From(ClinicalNoteResponse n) => new()
|
||||
{
|
||||
Id = n.Id,
|
||||
NoteType = n.NoteType.ToString(),
|
||||
ContentPreview = n.Content.Length > 80 ? n.Content[..80] + "…" : n.Content,
|
||||
ResponsibleClinicianRole = n.ResponsibleClinicianRole,
|
||||
IsSigned = n.IsSigned,
|
||||
CreatedAtUtc = n.CreatedAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
public class MedicationResultRow
|
||||
{
|
||||
public required string DisplayText { get; init; }
|
||||
public required string GenericName { get; init; }
|
||||
public string? AtcHint { get; init; }
|
||||
|
||||
public static MedicationResultRow From(MedicationSearchResponse m) => new()
|
||||
{
|
||||
DisplayText = m.BrandName is not null ? $"{m.BrandName} ({m.GenericName})" : m.GenericName,
|
||||
GenericName = m.GenericName
|
||||
};
|
||||
}
|
||||
|
||||
public partial class PatientJournalWindow : Window
|
||||
{
|
||||
private readonly Guid _patientId;
|
||||
private PrescriptionResponse? _selectedPrescription;
|
||||
|
||||
public PatientJournalWindow(Guid patientId, string patientDisplayName)
|
||||
{
|
||||
InitializeComponent();
|
||||
_patientId = patientId;
|
||||
PatientHeaderText.Text = $"Journal — {patientDisplayName}";
|
||||
_ = LoadNotesAsync();
|
||||
_ = LoadPrescriptionsAsync();
|
||||
}
|
||||
|
||||
// ---------- Journalanteckningar ----------
|
||||
|
||||
private async Task LoadNotesAsync()
|
||||
{
|
||||
var result = await App.ApiClient.GetClinicalNotesAsync(_patientId);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
NoteErrorText.Text = result.ErrorMessage ?? "Kunde inte hämta journalanteckningar.";
|
||||
return;
|
||||
}
|
||||
NotesGrid.ItemsSource = result.Value.OrderByDescending(n => n.CreatedAtUtc).Select(NoteRow.From).ToList();
|
||||
}
|
||||
|
||||
private void NotesGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
var row = NotesGrid.SelectedItem as NoteRow;
|
||||
SignNoteButton.IsEnabled = row is not null && !row.IsSigned;
|
||||
CorrectNoteButton.IsEnabled = row is not null;
|
||||
_selectedNoteId = row?.Id;
|
||||
}
|
||||
|
||||
private Guid? _selectedNoteId;
|
||||
|
||||
private async void SignNoteButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedNoteId is null) return;
|
||||
var result = await App.ApiClient.SignClinicalNoteAsync(_selectedNoteId.Value);
|
||||
if (!result.Success)
|
||||
{
|
||||
NoteErrorText.Text = result.ErrorMessage ?? "Kunde inte signera anteckningen.";
|
||||
return;
|
||||
}
|
||||
NoteErrorText.Text = string.Empty;
|
||||
await LoadNotesAsync();
|
||||
}
|
||||
|
||||
private async void CorrectNoteButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedNoteId is null) return;
|
||||
|
||||
var dialog = new CorrectNoteDialog { Owner = this };
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
|
||||
var result = await App.ApiClient.CorrectClinicalNoteAsync(
|
||||
new CorrectClinicalNoteRequest(_selectedNoteId.Value, dialog.CorrectedContent, dialog.Reason));
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
NoteErrorText.Text = result.ErrorMessage ?? "Kunde inte spara rättelsen.";
|
||||
return;
|
||||
}
|
||||
NoteErrorText.Text = string.Empty;
|
||||
await LoadNotesAsync();
|
||||
}
|
||||
|
||||
private async void SaveNoteButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
NoteErrorText.Text = string.Empty;
|
||||
|
||||
var noteTypeText = (NoteTypeCombo.SelectedItem as ComboBoxItem)?.Content?.ToString();
|
||||
if (noteTypeText is null || !Enum.TryParse<ClinicalNoteType>(noteTypeText, out var noteType))
|
||||
{
|
||||
NoteErrorText.Text = "Välj en typ.";
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(NoteContentBox.Text) || string.IsNullOrWhiteSpace(NoteClinicianRoleBox.Text))
|
||||
{
|
||||
NoteErrorText.Text = "Innehåll och yrkesroll krävs.";
|
||||
return;
|
||||
}
|
||||
|
||||
SaveNoteButton.IsEnabled = false;
|
||||
var result = await App.ApiClient.CreateClinicalNoteAsync(new CreateClinicalNoteRequest(
|
||||
_patientId, null, noteType, NoteContentBox.Text.Trim(), NoteClinicianRoleBox.Text.Trim()));
|
||||
SaveNoteButton.IsEnabled = true;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
NoteErrorText.Text = result.ErrorMessage ?? "Kunde inte spara anteckningen.";
|
||||
return;
|
||||
}
|
||||
|
||||
NoteContentBox.Clear();
|
||||
await LoadNotesAsync();
|
||||
}
|
||||
|
||||
// ---------- Recept / ordination ----------
|
||||
|
||||
private async Task LoadPrescriptionsAsync()
|
||||
{
|
||||
var result = await App.ApiClient.GetPrescriptionsAsync(_patientId);
|
||||
if (!result.Success || result.Value is null)
|
||||
{
|
||||
PrescriptionErrorText.Text = result.ErrorMessage ?? "Kunde inte hämta ordinationer.";
|
||||
return;
|
||||
}
|
||||
PrescriptionsGrid.ItemsSource = result.Value.OrderByDescending(p => p.CreatedAtUtc).ToList();
|
||||
}
|
||||
|
||||
private void PrescriptionsGrid_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_selectedPrescription = PrescriptionsGrid.SelectedItem as PrescriptionResponse;
|
||||
var isActive = _selectedPrescription?.Status == PrescriptionStatus.Active;
|
||||
|
||||
SignPrescriptionButton.IsEnabled = _selectedPrescription is not null && !_selectedPrescription.IsSigned;
|
||||
DiscontinueButton.IsEnabled = isActive;
|
||||
ReplaceButton.IsEnabled = isActive;
|
||||
}
|
||||
|
||||
private async void SignPrescriptionButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedPrescription is null) return;
|
||||
var result = await App.ApiClient.SignPrescriptionAsync(_selectedPrescription.Id);
|
||||
if (!result.Success)
|
||||
{
|
||||
PrescriptionErrorText.Text = result.ErrorMessage ?? "Kunde inte signera ordinationen.";
|
||||
return;
|
||||
}
|
||||
PrescriptionErrorText.Text = string.Empty;
|
||||
await LoadPrescriptionsAsync();
|
||||
}
|
||||
|
||||
private async void DiscontinueButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedPrescription is null) return;
|
||||
|
||||
var dialog = new TextInputDialog("Sätt ut ordination", "Anledning till utsättning:") { Owner = this };
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
|
||||
var result = await App.ApiClient.DiscontinuePrescriptionAsync(_selectedPrescription.Id, new DiscontinuePrescriptionRequest(dialog.InputText));
|
||||
if (!result.Success)
|
||||
{
|
||||
PrescriptionErrorText.Text = result.ErrorMessage ?? "Kunde inte sätta ut ordinationen.";
|
||||
return;
|
||||
}
|
||||
PrescriptionErrorText.Text = string.Empty;
|
||||
await LoadPrescriptionsAsync();
|
||||
}
|
||||
|
||||
private async void ReplaceButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_selectedPrescription is null) return;
|
||||
|
||||
var dialog = new ReplacePrescriptionDialog { Owner = this };
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
|
||||
var result = await App.ApiClient.ReplacePrescriptionAsync(_selectedPrescription.Id, new ReplacePrescriptionRequest(
|
||||
dialog.NewDosage, dialog.NewFrequency, dialog.NewRoute, dialog.Reason));
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
PrescriptionErrorText.Text = result.ErrorMessage ?? "Kunde inte ersätta ordinationen.";
|
||||
return;
|
||||
}
|
||||
PrescriptionErrorText.Text = string.Empty;
|
||||
await LoadPrescriptionsAsync();
|
||||
}
|
||||
|
||||
private async void SearchMedicationButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var query = MedicationSearchBox.Text.Trim();
|
||||
if (string.IsNullOrWhiteSpace(query)) return;
|
||||
|
||||
// Prova diagnos/indikation-sökning först - fungerar bäst för engelska
|
||||
// termer (openFDA), se README för begränsningar. Om inget hittas,
|
||||
// prova namnsökning istället.
|
||||
var result = await App.ApiClient.SearchMedicationsByDiagnosisAsync(query);
|
||||
if (!result.Success || result.Value is null || result.Value.Count == 0)
|
||||
result = await App.ApiClient.SearchMedicationsByNameAsync(query);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
PrescriptionErrorText.Text = result.ErrorMessage ?? "Sökningen misslyckades.";
|
||||
return;
|
||||
}
|
||||
|
||||
PrescriptionErrorText.Text = string.Empty;
|
||||
MedicationResultsList.ItemsSource = (result.Value ?? new()).Select(MedicationResultRow.From).ToList();
|
||||
}
|
||||
|
||||
private void MedicationResultsList_MouseDoubleClick(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (MedicationResultsList.SelectedItem is MedicationResultRow row)
|
||||
DrugNameBox.Text = row.GenericName;
|
||||
}
|
||||
|
||||
private async void SavePrescriptionButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
PrescriptionErrorText.Text = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DrugNameBox.Text) || string.IsNullOrWhiteSpace(DosageBox.Text) || string.IsNullOrWhiteSpace(FrequencyBox.Text))
|
||||
{
|
||||
PrescriptionErrorText.Text = "Läkemedel, dos och frekvens krävs.";
|
||||
return;
|
||||
}
|
||||
|
||||
SavePrescriptionButton.IsEnabled = false;
|
||||
var result = await App.ApiClient.CreatePrescriptionAsync(new CreatePrescriptionRequest(
|
||||
_patientId,
|
||||
DrugNameBox.Text.Trim(),
|
||||
string.IsNullOrWhiteSpace(AtcCodeBox.Text) ? null : AtcCodeBox.Text.Trim(),
|
||||
null,
|
||||
DosageBox.Text.Trim(),
|
||||
FrequencyBox.Text.Trim(),
|
||||
null,
|
||||
string.IsNullOrWhiteSpace(DiagnosisCodeBox.Text) ? null : DiagnosisCodeBox.Text.Trim(),
|
||||
string.IsNullOrWhiteSpace(DiagnosisDescriptionBox.Text) ? null : DiagnosisDescriptionBox.Text.Trim(),
|
||||
DateOnly.FromDateTime(DateTime.Today),
|
||||
null));
|
||||
SavePrescriptionButton.IsEnabled = true;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
PrescriptionErrorText.Text = result.ErrorMessage ?? "Kunde inte spara ordinationen.";
|
||||
return;
|
||||
}
|
||||
|
||||
DrugNameBox.Clear();
|
||||
AtcCodeBox.Clear();
|
||||
DosageBox.Clear();
|
||||
FrequencyBox.Clear();
|
||||
DiagnosisCodeBox.Clear();
|
||||
DiagnosisDescriptionBox.Clear();
|
||||
await LoadPrescriptionsAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<Window x:Class="Rinta.Desktop.Windows.PatientRegisterWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Registrera patient" Icon="Assets/rinta-icon.ico" Height="540" Width="420"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Registrera patient" FontSize="18" FontWeight="Bold" Margin="0,0,0,12" />
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Personnummer / samordningsnummer" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="2" x:Name="PersonalIdBox" Margin="0,0,0,2" />
|
||||
<TextBlock Grid.Row="3" Text="Lämna tomt om okänt - ett reservnummer skapas då automatiskt." FontSize="10" Foreground="Gray" Margin="0,0,0,10" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Grid.Row="4" Text="Förnamn *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="5" x:Name="FirstNameBox" Margin="0,0,0,10" />
|
||||
|
||||
<TextBlock Grid.Row="6" Text="Efternamn *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="7" x:Name="LastNameBox" Margin="0,0,0,10" />
|
||||
|
||||
<TextBlock Grid.Row="8" Text="Födelsedatum * (ÅÅÅÅ-MM-DD)" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="9" x:Name="DateOfBirthBox" Margin="0,0,0,10" />
|
||||
|
||||
<TextBlock Grid.Row="10" Text="Telefon" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="11" x:Name="PhoneBox" Margin="0,0,0,10" />
|
||||
|
||||
<TextBlock Grid.Row="12" Text="E-post" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="13" x:Name="EmailBox" Margin="0,0,0,10" />
|
||||
|
||||
<CheckBox Grid.Row="14" x:Name="ProtectedIdentityCheckBox" Content="Skyddad identitet / sekretessmarkering" Margin="0,0,0,10" />
|
||||
|
||||
<TextBlock Grid.Row="15" x:Name="ErrorText" Foreground="Red" TextWrapping="Wrap" Margin="0,0,0,8" Visibility="Collapsed" />
|
||||
|
||||
<StackPanel Grid.Row="16" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button x:Name="CancelButton" Content="Avbryt" Width="80" Margin="0,0,8,0" Click="CancelButton_Click" />
|
||||
<Button x:Name="SaveButton" Content="Registrera" Width="100" Click="SaveButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Window>
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Windows;
|
||||
using Rinta.Desktop.Models;
|
||||
|
||||
namespace Rinta.Desktop.Windows;
|
||||
|
||||
public partial class PatientRegisterWindow : Window
|
||||
{
|
||||
public PatientRegisterWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void SaveButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ErrorText.Visibility = Visibility.Collapsed;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(FirstNameBox.Text) || string.IsNullOrWhiteSpace(LastNameBox.Text))
|
||||
{
|
||||
ShowError("Förnamn och efternamn krävs.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!DateOnly.TryParse(DateOfBirthBox.Text.Trim(), out var dateOfBirth))
|
||||
{
|
||||
ShowError("Födelsedatum måste anges som ÅÅÅÅ-MM-DD.");
|
||||
return;
|
||||
}
|
||||
|
||||
SaveButton.IsEnabled = false;
|
||||
|
||||
var request = new CreatePatientRequest(
|
||||
string.IsNullOrWhiteSpace(PersonalIdBox.Text) ? null : PersonalIdBox.Text.Trim(),
|
||||
FirstNameBox.Text.Trim(),
|
||||
LastNameBox.Text.Trim(),
|
||||
dateOfBirth,
|
||||
null,
|
||||
string.IsNullOrWhiteSpace(PhoneBox.Text) ? null : PhoneBox.Text.Trim(),
|
||||
string.IsNullOrWhiteSpace(EmailBox.Text) ? null : EmailBox.Text.Trim(),
|
||||
null,
|
||||
ProtectedIdentityCheckBox.IsChecked == true);
|
||||
|
||||
var result = await App.ApiClient.CreatePatientAsync(request);
|
||||
|
||||
SaveButton.IsEnabled = true;
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
ShowError(result.ErrorMessage ?? "Registreringen misslyckades.");
|
||||
return;
|
||||
}
|
||||
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
ErrorText.Text = message;
|
||||
ErrorText.Visibility = Visibility.Visible;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<Window x:Class="Rinta.Desktop.Windows.ReplacePrescriptionDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Ändra dos" Icon="Assets/rinta-icon.ico" Height="440" Width="400" WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Text="Detta sätter ut den nuvarande ordinationen och skapar en ny, länkad ordination - originalet ändras aldrig."
|
||||
TextWrapping="Wrap" FontSize="11" Foreground="Gray" Margin="0,0,0,12" />
|
||||
|
||||
<TextBlock Grid.Row="1" Text="Ny dos *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="2" x:Name="DosageBox" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="3" Text="Ny frekvens *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="4" x:Name="FrequencyBox" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="5" Text="Ny administreringsväg (valfritt)" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="6" x:Name="RouteBox" Margin="0,0,0,8" />
|
||||
|
||||
<TextBlock Grid.Row="7" Text="Anledning till ändring *" Margin="0,0,0,2" />
|
||||
<TextBox Grid.Row="8" x:Name="ReasonBox" AcceptsReturn="True" TextWrapping="Wrap" VerticalAlignment="Top" Height="60" />
|
||||
|
||||
<StackPanel Grid.Row="9" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Avbryt" Width="80" Margin="0,0,8,0" Click="CancelButton_Click" />
|
||||
<Button Content="Spara" Width="90" Click="SaveButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Rinta.Desktop.Windows;
|
||||
|
||||
public partial class ReplacePrescriptionDialog : Window
|
||||
{
|
||||
public string NewDosage => DosageBox.Text.Trim();
|
||||
public string NewFrequency => FrequencyBox.Text.Trim();
|
||||
public string? NewRoute => string.IsNullOrWhiteSpace(RouteBox.Text) ? null : RouteBox.Text.Trim();
|
||||
public string Reason => ReasonBox.Text.Trim();
|
||||
|
||||
public ReplacePrescriptionDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void SaveButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DosageBox.Text) || string.IsNullOrWhiteSpace(FrequencyBox.Text) || string.IsNullOrWhiteSpace(ReasonBox.Text))
|
||||
{
|
||||
MessageBox.Show("Dos, frekvens och anledning krävs.", "Saknas", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Window x:Class="Rinta.Desktop.Windows.TextInputDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Ange text" Icon="Assets/rinta-icon.ico" Height="220" Width="420" WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" x:Name="PromptText" TextWrapping="Wrap" Margin="0,0,0,8" />
|
||||
<TextBox Grid.Row="1" x:Name="InputBox" AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" />
|
||||
|
||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="Avbryt" Width="80" Margin="0,0,8,0" Click="CancelButton_Click" />
|
||||
<Button Content="OK" Width="80" Click="OkButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Rinta.Desktop.Windows;
|
||||
|
||||
public partial class TextInputDialog : Window
|
||||
{
|
||||
public string InputText => InputBox.Text.Trim();
|
||||
|
||||
public TextInputDialog(string title, string prompt)
|
||||
{
|
||||
InitializeComponent();
|
||||
Title = title;
|
||||
PromptText.Text = prompt;
|
||||
}
|
||||
|
||||
private void OkButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(InputBox.Text))
|
||||
{
|
||||
MessageBox.Show("Fältet får inte vara tomt.", "Saknas", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
||||
private void CancelButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DialogResult = false;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ApiBaseUrl": "https://localhost:44380/"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Rinta.Domain.Common;
|
||||
|
||||
public enum SwedishIdentifierType
|
||||
{
|
||||
/// <summary>Vanligt personnummer, ÅÅÅÅMMDD-XXXX.</summary>
|
||||
PersonalIdentityNumber,
|
||||
/// <summary>Samordningsnummer - dag + 60, för personer utan personnummer
|
||||
/// men med känd identitet (t.ex. utländska medborgare i vissa flöden).</summary>
|
||||
CoordinationNumber,
|
||||
/// <summary>Lokalt reservnummer - genereras av OSS när patientens identitet
|
||||
/// ännu inte är bekräftad (medvetslös patient, nyfödd innan personnummer
|
||||
/// tilldelats, etc). Följer INTE Luhn-algoritmen eftersom det inte är ett
|
||||
/// riktigt personnummer - se ReservationNumberGenerator.</summary>
|
||||
ReservationNumber
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validerar svenska personnummer/samordningsnummer lokalt via
|
||||
/// Luhn-algoritmen (mod 10). Detta ersätter INTE en slagning mot
|
||||
/// folkbokföringen (SPAR) - det bekräftar bara att numret är
|
||||
/// matematiskt korrekt konstruerat, inte att personen faktiskt existerar
|
||||
/// eller att namnet stämmer. Så länge ni saknar SPAR-åtkomst är detta den
|
||||
/// enda automatiska kontroll som går att göra, och manuell inmatning av
|
||||
/// namn/adress är därför obligatorisk.
|
||||
/// </summary>
|
||||
public static class PersonnummerValidator
|
||||
{
|
||||
private static readonly Regex Format = new(@"^(\d{2})?(\d{2})(\d{2})(\d{2})[-+]?(\d{4})$", RegexOptions.Compiled);
|
||||
|
||||
public static bool TryValidate(string input, out SwedishIdentifierType identifierType, out string normalized)
|
||||
{
|
||||
identifierType = default;
|
||||
normalized = string.Empty;
|
||||
|
||||
var digitsOnly = Regex.Replace(input, @"[^\d]", "");
|
||||
var match = Format.Match(input.Trim());
|
||||
if (!match.Success) return false;
|
||||
|
||||
var month = int.Parse(match.Groups[2].Value);
|
||||
var day = int.Parse(match.Groups[3].Value);
|
||||
var isCoordinationNumber = day > 60;
|
||||
var actualDay = isCoordinationNumber ? day - 60 : day;
|
||||
|
||||
if (month is < 1 or > 12) return false;
|
||||
if (actualDay is < 1 or > 31) return false;
|
||||
|
||||
// Luhn-kontroll görs alltid på de 10 sista siffrorna (utan sekelsiffra).
|
||||
var tenDigitForm = digitsOnly.Length switch
|
||||
{
|
||||
10 => digitsOnly,
|
||||
12 => digitsOnly[2..],
|
||||
_ => null
|
||||
};
|
||||
if (tenDigitForm is null) return false;
|
||||
if (!PassesLuhnCheck(tenDigitForm)) return false;
|
||||
|
||||
identifierType = isCoordinationNumber
|
||||
? SwedishIdentifierType.CoordinationNumber
|
||||
: SwedishIdentifierType.PersonalIdentityNumber;
|
||||
|
||||
normalized = $"{tenDigitForm[..6]}-{tenDigitForm[6..]}";
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool PassesLuhnCheck(string tenDigits)
|
||||
{
|
||||
var sum = 0;
|
||||
for (var i = 0; i < tenDigits.Length; i++)
|
||||
{
|
||||
var digit = tenDigits[i] - '0';
|
||||
if (i % 2 == 0) // varannan siffra (0-indexerat: position 0,2,4...) multipliceras med 2
|
||||
{
|
||||
digit *= 2;
|
||||
if (digit > 9) digit -= 9;
|
||||
}
|
||||
sum += digit;
|
||||
}
|
||||
return sum % 10 == 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Rinta.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Genererar reservnummer i formatet ÅÅMMDD-RxxxN (R markerar tydligt att
|
||||
/// det INTE är ett riktigt personnummer, i motsats till hur vissa regioner
|
||||
/// istället använder bokstäver A-Z i sista positionen - se regional
|
||||
/// anpassning nedan). Reservnumret är unikt PER TENANT (vårdgivare),
|
||||
/// inte nationellt unikt - det ska aldrig lämna er egen organisation som
|
||||
/// om det vore ett riktigt personnummer.
|
||||
///
|
||||
/// OBS: exakt format på reservnummer varierar mellan regioner i Sverige
|
||||
/// (t.ex. Region Stockholm respektive Västra Götaland har historiskt haft
|
||||
/// något olika konventioner). Detta är en enkel, egen konvention - om ni
|
||||
/// integrerar mot ett regionalt system senare kan formatet behöva anpassas
|
||||
/// till den regionens standard.
|
||||
/// </summary>
|
||||
public static class ReservationNumberGenerator
|
||||
{
|
||||
public static string Generate(DateOnly approximateDateOfBirth, int sequenceNumber)
|
||||
{
|
||||
if (sequenceNumber is < 0 or > 999)
|
||||
throw new ArgumentOutOfRangeException(nameof(sequenceNumber), "Måste vara 0-999.");
|
||||
|
||||
var datePart = approximateDateOfBirth.ToString("yyMMdd");
|
||||
return $"{datePart}-R{sequenceNumber:D3}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace Rinta.Domain.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Bas för alla entiteter som tillhör en specifik vårdgivare (tenant).
|
||||
/// TenantId används av EF Cores globala query filter för att garantera
|
||||
/// att en vårdgivare aldrig kan se eller råka fråga fram en annan
|
||||
/// vårdgivares patientdata.
|
||||
/// </summary>
|
||||
public abstract class TenantEntityBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string CreatedByUserId { get; set; } = string.Empty;
|
||||
|
||||
// OBS: UpdatedAt/UpdatedBy finns medvetet INTE på ClinicalNote (se den klassen)
|
||||
// eftersom journalanteckningar är append-only. För övriga entiteter (t.ex.
|
||||
// Patient) är detta ett vanligt "senast ändrad"-fält.
|
||||
public DateTimeOffset? UpdatedAtUtc { get; set; }
|
||||
public string? UpdatedByUserId { get; set; }
|
||||
|
||||
public bool IsDeleted { get; set; } = false; // soft delete - patientdata raderas aldrig hårt
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ger DbContext/middleware tillgång till "vem är inloggad och för vilken
|
||||
/// tenant" utan att domän- eller repository-lagret behöver känna till HTTP.
|
||||
/// </summary>
|
||||
public interface ITenantProvider
|
||||
{
|
||||
Guid GetCurrentTenantId();
|
||||
string GetCurrentUserId();
|
||||
|
||||
/// <summary>
|
||||
/// "Break-glass"-åtkomst: en läkare kan i en akutsituation behöva komma åt
|
||||
/// journaldata utanför sin normala tenant-behörighet. Detta måste ALLTID
|
||||
/// resultera i en särskild, tydligt märkt audit-post och (i en riktig
|
||||
/// driftsättning) en efterhandsgranskning. Se AuditLogEntry.IsBreakGlassAccess.
|
||||
/// </summary>
|
||||
bool IsBreakGlassAccess();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Rinta.Domain.Common;
|
||||
|
||||
namespace Rinta.Domain.Entities;
|
||||
|
||||
public enum AppointmentStatus
|
||||
{
|
||||
Scheduled,
|
||||
CheckedIn,
|
||||
Completed,
|
||||
Cancelled,
|
||||
NoShow
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// En bokad tid för en patient hos en kliniker. Till skillnad från
|
||||
/// ClinicalNote är detta INTE append-only - tider kan bokas om och
|
||||
/// avbokas, men varje ändring går genom AuditingSaveChangesInterceptor
|
||||
/// precis som allt annat i systemet.
|
||||
/// </summary>
|
||||
public class Appointment : TenantEntityBase
|
||||
{
|
||||
public Guid PatientId { get; set; }
|
||||
public Patient? Patient { get; set; }
|
||||
|
||||
/// <summary>Userid för den kliniker (läkare/sköterska) som tiden är bokad hos.</summary>
|
||||
public string ClinicianUserId { get; set; } = string.Empty;
|
||||
|
||||
public DateTimeOffset StartUtc { get; set; }
|
||||
public DateTimeOffset EndUtc { get; set; }
|
||||
|
||||
public AppointmentStatus Status { get; set; } = AppointmentStatus.Scheduled;
|
||||
|
||||
public string? Reason { get; set; }
|
||||
public string? Location { get; set; } // t.ex. rumsnummer eller "Distans"
|
||||
|
||||
public string? CancellationReason { get; set; }
|
||||
public DateTimeOffset? CancelledAtUtc { get; set; }
|
||||
|
||||
/// <summary>Om en tid bokas om skapas en ny rad; denna länkar till föregångaren
|
||||
/// så historiken går att följa (t.ex. "patient X har ombokat 3 gånger").</summary>
|
||||
public Guid? RescheduledFromAppointmentId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace Rinta.Domain.Entities;
|
||||
|
||||
public enum AuditAction
|
||||
{
|
||||
Read,
|
||||
Create,
|
||||
Update,
|
||||
SoftDelete,
|
||||
Sign,
|
||||
Correction
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append-only logg över ALL åtkomst till patientdata - läsningar såväl som
|
||||
/// skrivningar. Detta är ett lagkrav (Patientdatalagen 4 kap - loggning och
|
||||
/// spårbarhet) och grunden för att patienter ska kunna begära ut "vem har
|
||||
/// läst min journal". Denna tabell ska ALDRIG rensas eller skrivas över
|
||||
/// och bör i produktion ligga i ett separat, append-only-optimerat lager
|
||||
/// (t.ex. egen databas eller write-once storage).
|
||||
/// </summary>
|
||||
public class AuditLogEntry
|
||||
{
|
||||
public long Id { get; set; } // sekventiellt, aldrig återanvänt
|
||||
public Guid TenantId { get; set; }
|
||||
public DateTimeOffset TimestampUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string UserRole { get; set; } = string.Empty;
|
||||
|
||||
public AuditAction Action { get; set; }
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public Guid EntityId { get; set; }
|
||||
|
||||
public Guid? PatientId { get; set; } // denormaliserat för snabb "vem har läst patient X"-sökning
|
||||
|
||||
public bool IsBreakGlassAccess { get; set; } = false;
|
||||
public string? BreakGlassJustification { get; set; }
|
||||
|
||||
/// <summary>JSON-diff eller snapshot, beroende på Action. Inga PersonalIdentityNumber i klartext.</summary>
|
||||
public string? DetailsJson { get; set; }
|
||||
|
||||
public string? SourceIpAddress { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Rinta.Domain.Common;
|
||||
|
||||
namespace Rinta.Domain.Entities;
|
||||
|
||||
public enum ClinicalNoteType
|
||||
{
|
||||
Anamnes,
|
||||
StatusPresens,
|
||||
Bedomning,
|
||||
Atgardsplan,
|
||||
Epikris,
|
||||
TelefonRadgivning,
|
||||
Ovrigt
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// En enskild journalanteckning. Enligt Patientdatalagen (2008:355) 3 kap 8§
|
||||
/// får en journalanteckning inte raderas eller ändras i efterhand - endast
|
||||
/// rättas genom en ny, länkad anteckning (CorrectionOfNoteId) som tydligt
|
||||
/// hänvisar till originalet. Detta är därför en append-only-modell:
|
||||
///
|
||||
/// - Inget Update-API-anrop existerar för denna entitet.
|
||||
/// - "Rättelse" = skapa en ny ClinicalNote med CorrectionOfNoteId satt.
|
||||
/// - "Radering" hanteras endast av Socialstyrelsen-godkänd process
|
||||
/// (t.ex. patientens begäran, IVO-beslut) och sker i så fall som ett
|
||||
/// separat, extremt loggat administrativt flöde - inte via detta API.
|
||||
/// </summary>
|
||||
public class ClinicalNote : TenantEntityBase
|
||||
{
|
||||
public Guid PatientId { get; set; }
|
||||
public Patient? Patient { get; set; }
|
||||
|
||||
public Guid? EncounterId { get; set; }
|
||||
|
||||
public ClinicalNoteType NoteType { get; set; }
|
||||
|
||||
public string Content { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Ansvarig yrkesutövare (läkare/sköterska/etc), inte nödvändigtvis samma som CreatedByUserId.</summary>
|
||||
public string ResponsibleClinicianUserId { get; set; } = string.Empty;
|
||||
public string ResponsibleClinicianRole { get; set; } = string.Empty; // t.ex. "Läkare", "Sjuksköterska"
|
||||
|
||||
/// <summary>Signerad = låst. Osignerade anteckningar (utkast) kan redigeras av
|
||||
/// författaren fram tills signering - därefter är de permanent immutabla.</summary>
|
||||
public bool IsSigned { get; set; } = false;
|
||||
public DateTimeOffset? SignedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Sätts när denna anteckning är en rättelse av en tidigare anteckning.</summary>
|
||||
public Guid? CorrectionOfNoteId { get; set; }
|
||||
|
||||
public bool WasCreatedUnderBreakGlassAccess { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Rinta.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Återkommande arbetstider för en kliniker, per veckodag. Används för att
|
||||
/// räkna ut lediga bokningsbara luckor (se AppointmentsController.GetAvailability).
|
||||
/// Enkel modell medvetet - hanterar inte semester/frånvaro/undantagsdagar
|
||||
/// än (se README, punkt "nästa moduler").
|
||||
/// </summary>
|
||||
public class ClinicianWorkingHours
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid TenantId { get; set; }
|
||||
public string ClinicianUserId { get; set; } = string.Empty;
|
||||
|
||||
public DayOfWeek DayOfWeek { get; set; }
|
||||
public TimeOnly StartTime { get; set; }
|
||||
public TimeOnly EndTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Rinta.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// En lokalt cachad post från en extern läkemedelskälla (idag openFDA, se
|
||||
/// README för begränsningar). Detta är DELAD referensdata - inte
|
||||
/// patientkänsligt och inte tenant-scopad, till skillnad från Prescription.
|
||||
/// Cachning minskar antalet externa anrop och ger systemet något att
|
||||
/// fungera mot om den externa källan är nere.
|
||||
/// </summary>
|
||||
public class MedicationCatalogEntry
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public string SourceApi { get; set; } = "openFDA"; // t.ex. "openFDA", framtida "NLL", "FASS"
|
||||
public string? SourceRecordId { get; set; } // t.ex. openFDA set_id
|
||||
|
||||
public string GenericName { get; set; } = string.Empty;
|
||||
public string? BrandName { get; set; }
|
||||
public string? AtcCode { get; set; }
|
||||
|
||||
/// <summary>Rå indikationstext från källan (på källans originalspråk - för openFDA: engelska).</summary>
|
||||
public string? IndicationsText { get; set; }
|
||||
|
||||
public DateTimeOffset LastRefreshedUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using Rinta.Domain.Common;
|
||||
|
||||
namespace Rinta.Domain.Entities;
|
||||
|
||||
public class Patient : TenantEntityBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Svenskt personnummer, samordningsnummer, eller lokalt reservnummer -
|
||||
/// se IdentityType. Lagrat krypterat i databasen (se DbContext-
|
||||
/// konfiguration: value converter + kolumnkryptering). Visas maskerat
|
||||
/// (ÅÅMMDD-XXXX) i loggar/UI som standard.
|
||||
/// </summary>
|
||||
public string PersonalIdentityNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Deterministisk HMAC-hash av det normaliserade personnumret -
|
||||
/// används för dubblettkontroll och uppslag, se IIdentifierHasher.
|
||||
/// Den krypterade PersonalIdentityNumber-kolumnen kan INTE användas för
|
||||
/// detta eftersom AES-krypteringen har slumpmässig IV per anrop.</summary>
|
||||
public string PersonalIdentityNumberHash { get; set; } = string.Empty;
|
||||
|
||||
public SwedishIdentifierType IdentityType { get; set; } = SwedishIdentifierType.PersonalIdentityNumber;
|
||||
|
||||
/// <summary>
|
||||
/// False för patienter registrerade med reservnummer tills identiteten
|
||||
/// bekräftats (se PatientsController.ConfirmIdentity). True per
|
||||
/// definition för PersonalIdentityNumber/CoordinationNumber, eftersom de
|
||||
/// redan är Luhn-validerade vid registrering.
|
||||
/// </summary>
|
||||
public bool IsIdentityConfirmed { get; set; } = true;
|
||||
|
||||
/// <summary>Om patienten ursprungligen registrerades med reservnummer,
|
||||
/// sparas det här efter att identiteten bekräftats - för spårbarhet.</summary>
|
||||
public string? PreviousReservationNumber { get; set; }
|
||||
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
|
||||
public DateOnly DateOfBirth { get; set; }
|
||||
public string? Sex { get; set; }
|
||||
|
||||
public string? PhoneNumber { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Address { get; set; }
|
||||
|
||||
/// <summary>Spärrmarkering / sekretessmarkering enligt Offentlighets- och sekretesslagen.</summary>
|
||||
public bool HasProtectedIdentity { get; set; } = false;
|
||||
|
||||
public ICollection<ClinicalNote> ClinicalNotes { get; set; } = new List<ClinicalNote>();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Rinta.Domain.Common;
|
||||
|
||||
namespace Rinta.Domain.Entities;
|
||||
|
||||
public enum PrescriptionStatus
|
||||
{
|
||||
Active,
|
||||
Discontinued,
|
||||
Completed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// En läkemedelsordination. Precis som ClinicalNote räknas detta som
|
||||
/// journaldata enligt Patientdatalagen och följer samma append-only-princip:
|
||||
/// en ordination ändras aldrig i efterhand. Att "ändra dos" innebär att
|
||||
/// den gamla ordinationen sätts ut (Discontinue) och en ny skapas, länkad
|
||||
/// via ReplacesPrescriptionId - precis som rättelseflödet för ClinicalNote.
|
||||
/// </summary>
|
||||
public class Prescription : TenantEntityBase
|
||||
{
|
||||
public Guid PatientId { get; set; }
|
||||
public Patient? Patient { get; set; }
|
||||
|
||||
/// <summary>Fritextnamn på preparatet, t.ex. "Alvedon 500 mg tablett".</summary>
|
||||
public string DrugName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>WHO ATC-kod (Anatomical Therapeutic Chemical), t.ex. "N02BE01" för paracetamol.
|
||||
/// ATC är den internationella klassificeringsstandard som även svensk sjukvård använder.</summary>
|
||||
public string? AtcCode { get; set; }
|
||||
|
||||
/// <summary>Om ordinationen härrör från en slagning mot extern läkemedelskälla.</summary>
|
||||
public Guid? MedicationCatalogEntryId { get; set; }
|
||||
|
||||
public string Dosage { get; set; } = string.Empty; // t.ex. "1 tablett"
|
||||
public string Frequency { get; set; } = string.Empty; // t.ex. "3 gånger dagligen vid behov"
|
||||
public string? Route { get; set; } // t.ex. "Oralt", "Intravenöst"
|
||||
|
||||
/// <summary>ICD-10-kod för diagnosen ordinationen avser, t.ex. "R51" (huvudvärk).</summary>
|
||||
public string? DiagnosisCode { get; set; }
|
||||
public string? DiagnosisDescription { get; set; }
|
||||
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly? PlannedEndDate { get; set; }
|
||||
|
||||
public string PrescribingClinicianUserId { get; set; } = string.Empty;
|
||||
|
||||
public bool IsSigned { get; set; } = false;
|
||||
public DateTimeOffset? SignedAtUtc { get; set; }
|
||||
|
||||
public PrescriptionStatus Status { get; set; } = PrescriptionStatus.Active;
|
||||
public DateTimeOffset? DiscontinuedAtUtc { get; set; }
|
||||
public string? DiscontinuationReason { get; set; }
|
||||
|
||||
/// <summary>Sätts när denna ordination ersätter en tidigare (t.ex. dosjustering).</summary>
|
||||
public Guid? ReplacesPrescriptionId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Rinta.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// En vårdgivare (klinik, region, privat mottagningskedja etc.) i systemet.
|
||||
/// All klinisk data är partitionerad per Tenant.
|
||||
/// </summary>
|
||||
public class Tenant
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>HSA-id eller motsvarande nationellt vårdgivar-id (om tillämpligt).</summary>
|
||||
public string? NationalProviderId { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json;
|
||||
using Rinta.Domain.Common;
|
||||
using Rinta.Domain.Entities;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace Rinta.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Fångar varje Create/Update/SoftDelete på tenant-scopade entiteter och
|
||||
/// skriver en motsvarande AuditLogEntry i SAMMA transaktion som den
|
||||
/// egentliga ändringen. Läsningar (Action.Read) loggas separat, explicit,
|
||||
/// i respektive controller/service - de går inte via SaveChanges.
|
||||
/// </summary>
|
||||
public class AuditingSaveChangesInterceptor : SaveChangesInterceptor
|
||||
{
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public AuditingSaveChangesInterceptor(ITenantProvider tenantProvider, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_tenantProvider = tenantProvider;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
|
||||
{
|
||||
if (eventData.Context is not null)
|
||||
InjectAuditEntries(eventData.Context);
|
||||
return base.SavingChanges(eventData, result);
|
||||
}
|
||||
|
||||
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
|
||||
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (eventData.Context is not null)
|
||||
InjectAuditEntries(eventData.Context);
|
||||
return base.SavingChangesAsync(eventData, result, cancellationToken);
|
||||
}
|
||||
|
||||
private void InjectAuditEntries(DbContext context)
|
||||
{
|
||||
var relevantEntries = context.ChangeTracker.Entries()
|
||||
.Where(e => e.Entity is not AuditLogEntry
|
||||
&& e.Entity is TenantEntityBase
|
||||
&& e.State is EntityState.Added or EntityState.Modified)
|
||||
.ToList();
|
||||
|
||||
// Inget att logga (t.ex. vid seeding av Tenant/ApplicationUser vid
|
||||
// uppstart, som körs UTANFÖR en HTTP-request och alltså saknar en
|
||||
// inloggad användare/tenant) - hoppa ur INNAN ITenantProvider anropas,
|
||||
// annars kastar den ett UnauthorizedAccessException i onödan.
|
||||
if (relevantEntries.Count == 0) return;
|
||||
|
||||
var userId = _tenantProvider.GetCurrentUserId();
|
||||
var tenantId = _tenantProvider.GetCurrentTenantId();
|
||||
var isBreakGlass = _tenantProvider.IsBreakGlassAccess();
|
||||
var ip = _httpContextAccessor.HttpContext?.Connection?.RemoteIpAddress?.ToString();
|
||||
var role = _httpContextAccessor.HttpContext?.User?.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "unknown";
|
||||
|
||||
foreach (var entry in relevantEntries)
|
||||
{
|
||||
var tenantEntity = (TenantEntityBase)entry.Entity;
|
||||
|
||||
AuditAction action;
|
||||
string? detailsJson = null;
|
||||
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
action = AuditAction.Create;
|
||||
if (entry.Entity is ClinicalNote note && note.CorrectionOfNoteId.HasValue)
|
||||
action = AuditAction.Correction;
|
||||
}
|
||||
else
|
||||
{
|
||||
// "Modified" på en TenantEntityBase ska i praktiken bara ske för
|
||||
// soft-delete eller Sign, eftersom ClinicalNote annars är append-only.
|
||||
var isDeleted = entry.Property(nameof(TenantEntityBase.IsDeleted));
|
||||
action = isDeleted.IsModified && tenantEntity.IsDeleted
|
||||
? AuditAction.SoftDelete
|
||||
: AuditAction.Update;
|
||||
|
||||
var changed = entry.Properties
|
||||
.Where(p => p.IsModified)
|
||||
.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue);
|
||||
detailsJson = JsonSerializer.Serialize(changed);
|
||||
}
|
||||
|
||||
Guid? patientId = tenantEntity switch
|
||||
{
|
||||
Patient p => p.Id,
|
||||
ClinicalNote n => n.PatientId,
|
||||
_ => null
|
||||
};
|
||||
|
||||
context.Add(new AuditLogEntry
|
||||
{
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
UserRole = role,
|
||||
Action = action,
|
||||
EntityType = entry.Entity.GetType().Name,
|
||||
EntityId = tenantEntity.Id,
|
||||
PatientId = patientId,
|
||||
IsBreakGlassAccess = isBreakGlass,
|
||||
DetailsJson = detailsJson,
|
||||
SourceIpAddress = ip,
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using Rinta.Domain.Common;
|
||||
using Rinta.Domain.Entities;
|
||||
using Rinta.Infrastructure.Identity;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Rinta.Infrastructure.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Ärver IdentityDbContext istället för ren DbContext - det ger oss
|
||||
/// AspNetUsers/AspNetRoles-tabellerna för lokal inloggning (se
|
||||
/// ApplicationUser) utan extern SSO-beroende.
|
||||
/// </summary>
|
||||
public class JournalDbContext : IdentityDbContext<ApplicationUser, IdentityRole, string>
|
||||
{
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
private readonly byte[] _encryptionKey; // i produktion: från Key Vault / secret manager, ALDRIG i config-fil
|
||||
|
||||
public DbSet<Tenant> Tenants => Set<Tenant>();
|
||||
public DbSet<Patient> Patients => Set<Patient>();
|
||||
public DbSet<ClinicalNote> ClinicalNotes => Set<ClinicalNote>();
|
||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
||||
public DbSet<Appointment> Appointments => Set<Appointment>();
|
||||
public DbSet<ClinicianWorkingHours> ClinicianWorkingHours => Set<ClinicianWorkingHours>();
|
||||
public DbSet<Prescription> Prescriptions => Set<Prescription>();
|
||||
public DbSet<MedicationCatalogEntry> MedicationCatalogEntries => Set<MedicationCatalogEntry>();
|
||||
|
||||
public JournalDbContext(
|
||||
DbContextOptions<JournalDbContext> options,
|
||||
ITenantProvider tenantProvider,
|
||||
IConfiguration configuration) : base(options)
|
||||
{
|
||||
_tenantProvider = tenantProvider;
|
||||
var keyBase64 = configuration["Encryption:ColumnKey"]
|
||||
?? throw new InvalidOperationException("Encryption:ColumnKey saknas i konfigurationen.");
|
||||
_encryptionKey = Convert.FromBase64String(keyBase64);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder); // krävs av IdentityDbContext för AspNetUsers/AspNetRoles m.fl.
|
||||
|
||||
var encryptedStringConverter = new ValueConverter<string, string>(
|
||||
v => Encrypt(v),
|
||||
v => Decrypt(v));
|
||||
|
||||
modelBuilder.Entity<ApplicationUser>(e =>
|
||||
{
|
||||
// Medvetet INGET query filter här - inloggning (FindByNameAsync)
|
||||
// måste kunna hitta användaren innan tenant_id ens är känt.
|
||||
// Tenant-filtrering av VILKA ANVÄNDARE en admin får LISTA/SKAPA
|
||||
// sker istället explicit i UsersController.
|
||||
e.HasIndex(u => u.TenantId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Tenant>(e =>
|
||||
{
|
||||
// MariaDB (till skillnad från Postgres) kan inte indexera en
|
||||
// TEXT/LONGTEXT-kolumn utan en explicit maxlängd - därför
|
||||
// HasMaxLength på alla strängkolumner som ingår i ett index
|
||||
// nedan, annars kastar `dotnet ef database update` felet
|
||||
// "BLOB/TEXT column used in key specification without a key length".
|
||||
e.Property(t => t.NationalProviderId).HasMaxLength(50);
|
||||
e.HasIndex(t => t.NationalProviderId).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Patient>(e =>
|
||||
{
|
||||
e.Property(p => p.PersonalIdentityNumber)
|
||||
.HasConversion(encryptedStringConverter)
|
||||
.HasMaxLength(500) // krypterad + base64 - rymligt tak
|
||||
.IsRequired();
|
||||
|
||||
e.Property(p => p.PersonalIdentityNumberHash).HasMaxLength(64); // hex-SHA256, alltid exakt 64 tecken
|
||||
e.Property(p => p.LastName).HasMaxLength(200);
|
||||
|
||||
// Sökbarhet/dubblettkontroll löses via en separat, deterministisk
|
||||
// HMAC-hash-kolumn (PersonalIdentityNumberHash) - se
|
||||
// HmacIdentifierHasher. Det krypterade fältet ovan går INTE att
|
||||
// söka eller jämföra likhet på direkt, eftersom AES-IV är
|
||||
// slumpmässig per anrop.
|
||||
e.HasIndex(p => new { p.TenantId, p.PersonalIdentityNumberHash }).IsUnique();
|
||||
e.HasIndex(p => new { p.TenantId, p.LastName, p.DateOfBirth });
|
||||
|
||||
// GLOBAL QUERY FILTER: garanterar att varje fråga mot Patients
|
||||
// automatiskt begränsas till aktuell tenant + exkluderar soft-deletade
|
||||
// rader. Detta gäller ALLA LINQ-frågor mot DbSet<Patient>, även om
|
||||
// en utvecklare glömmer ett explicit .Where(TenantId == ...).
|
||||
e.HasQueryFilter(p =>
|
||||
p.TenantId == _tenantProvider.GetCurrentTenantId() && !p.IsDeleted);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClinicalNote>(e =>
|
||||
{
|
||||
e.HasIndex(n => new { n.TenantId, n.PatientId });
|
||||
|
||||
e.HasOne(n => n.Patient)
|
||||
.WithMany(p => p.ClinicalNotes)
|
||||
.HasForeignKey(n => n.PatientId)
|
||||
.OnDelete(DeleteBehavior.Restrict); // journalanteckningar får aldrig cascade-raderas
|
||||
|
||||
e.HasQueryFilter(n =>
|
||||
n.TenantId == _tenantProvider.GetCurrentTenantId() && !n.IsDeleted);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Appointment>(e =>
|
||||
{
|
||||
e.Property(a => a.ClinicianUserId).HasMaxLength(450); // matchar ASP.NET Identitys egen konvention för användar-id
|
||||
|
||||
e.HasIndex(a => new { a.TenantId, a.ClinicianUserId, a.StartUtc });
|
||||
e.HasIndex(a => new { a.TenantId, a.PatientId });
|
||||
|
||||
e.HasOne(a => a.Patient)
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.PatientId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasQueryFilter(a =>
|
||||
a.TenantId == _tenantProvider.GetCurrentTenantId() && !a.IsDeleted);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ClinicianWorkingHours>(e =>
|
||||
{
|
||||
e.Property(w => w.ClinicianUserId).HasMaxLength(450);
|
||||
e.HasIndex(w => new { w.TenantId, w.ClinicianUserId, w.DayOfWeek });
|
||||
e.HasQueryFilter(w => w.TenantId == _tenantProvider.GetCurrentTenantId());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Prescription>(e =>
|
||||
{
|
||||
e.HasIndex(p => new { p.TenantId, p.PatientId });
|
||||
e.HasIndex(p => new { p.TenantId, p.Status });
|
||||
|
||||
e.HasOne(p => p.Patient)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.PatientId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasQueryFilter(p =>
|
||||
p.TenantId == _tenantProvider.GetCurrentTenantId() && !p.IsDeleted);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MedicationCatalogEntry>(e =>
|
||||
{
|
||||
e.Property(m => m.SourceApi).HasMaxLength(50);
|
||||
e.Property(m => m.SourceRecordId).HasMaxLength(200);
|
||||
e.Property(m => m.GenericName).HasMaxLength(300);
|
||||
|
||||
// Delad referensdata - medvetet INGET tenant query filter.
|
||||
e.HasIndex(m => new { m.SourceApi, m.SourceRecordId }).IsUnique();
|
||||
e.HasIndex(m => m.GenericName);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AuditLogEntry>(e =>
|
||||
{
|
||||
e.HasIndex(a => new { a.TenantId, a.PatientId, a.TimestampUtc });
|
||||
e.HasIndex(a => a.TimestampUtc);
|
||||
// Notera: AuditLogEntry har medvetet INGET query filter på TenantId
|
||||
// eftersom en systemadministratör med rätt behörighet kan behöva
|
||||
// tvärgranska - det är endast API-lagret (auktorisering) som
|
||||
// begränsar vem som får läsa den, inte databaslagret.
|
||||
});
|
||||
}
|
||||
|
||||
private string Encrypt(string plaintext)
|
||||
{
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = _encryptionKey;
|
||||
aes.GenerateIV();
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
|
||||
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
||||
return Convert.ToBase64String(aes.IV.Concat(cipherBytes).ToArray());
|
||||
}
|
||||
|
||||
private string Decrypt(string ciphertextWithIv)
|
||||
{
|
||||
var all = Convert.FromBase64String(ciphertextWithIv);
|
||||
using var aes = Aes.Create();
|
||||
aes.Key = _encryptionKey;
|
||||
aes.IV = all.Take(16).ToArray();
|
||||
using var decryptor = aes.CreateDecryptor();
|
||||
var cipherBytes = all.Skip(16).ToArray();
|
||||
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
|
||||
return Encoding.UTF8.GetString(plainBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Rinta.Infrastructure.ExternalApis;
|
||||
|
||||
public record MedicationLookupResult(
|
||||
string GenericName,
|
||||
string? BrandName,
|
||||
string? IndicationsText,
|
||||
string SourceApi,
|
||||
string? SourceRecordId
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Abstraktion för läkemedelsuppslag mot extern källa. Idag finns bara
|
||||
/// OpenFdaMedicationLookupService, men detta interface gör det möjligt att
|
||||
/// senare lägga till en NllMedicationLookupService eller
|
||||
/// FassMedicationLookupService utan att röra Controllers.
|
||||
/// </summary>
|
||||
public interface IMedicationLookupService
|
||||
{
|
||||
Task<List<MedicationLookupResult>> SearchByIndicationAsync(string diagnosisText, int limit = 10);
|
||||
Task<List<MedicationLookupResult>> SearchByNameAsync(string drugName, int limit = 10);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace Rinta.Infrastructure.ExternalApis;
|
||||
|
||||
public record NavetLookupResult(
|
||||
string PersonalIdentityNumber,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string? Address,
|
||||
string? PostalCode,
|
||||
string? City,
|
||||
bool HasProtectedIdentity,
|
||||
bool IsDeceased
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Abstraktion för uppslag mot Navet (Skatteverkets system för
|
||||
/// folkbokföringsuppgifter). Se NavetLookupService för status - detta är
|
||||
/// FÖRBEREDD men INTE AKTIV integration; ni saknar ännu godkänd åtkomst
|
||||
/// och certifikat. Tänkt användning när det är på plats: slå upp namn/
|
||||
/// adress automatiskt vid PatientService.CreateAsync/ConfirmIdentity
|
||||
/// istället för att lita helt på manuell inmatning.
|
||||
/// </summary>
|
||||
public interface INavetLookupService
|
||||
{
|
||||
Task<NavetLookupResult?> LookupByIdentifierAsync(string personalIdentityNumber);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Rinta.Infrastructure.ExternalApis;
|
||||
|
||||
/// <summary>
|
||||
/// FÖRBEREDD men INTE FUNKTIONELL integration mot Navet (Skatteverkets
|
||||
/// system för folkbokföringsuppgifter, "Folkbokföringsuppgifter för
|
||||
/// offentliga aktörer").
|
||||
///
|
||||
/// VAD SOM SAKNAS INNAN DETTA KAN AKTIVERAS:
|
||||
///
|
||||
/// 1. Navet är ett RIKTAT API - inte fritt tillgängligt. Det riktar sig
|
||||
/// till myndigheter, kommuner och regioner (och programvaruföretag som
|
||||
/// levererar system till dessa). En ansökan måste godkännas av
|
||||
/// Skatteverket innan ni ens kommer åt testmiljön.
|
||||
/// Ansökningsformulär: https://www.skatteverket.se/omoss/digitalasamarbeten/anslutningochsupportforvaraapierochoppnadata/begartillgangtillvaraapier/begartillgangtillriktadeapier.html
|
||||
///
|
||||
/// 2. Efter godkännande krävs ett KLIENTCERTIFIKAT knutet till ert
|
||||
/// organisationsnummer - anropen autentiseras via mTLS, inte en enkel
|
||||
/// API-nyckel. Testmiljöns bas-URL är https://api.test.skatteverket.se,
|
||||
/// men den kräver ändå detta certifikat för att svara på riktigt.
|
||||
///
|
||||
/// 3. Testmiljön förväntar sig en specifik BESTÄLLNING/orderkod
|
||||
/// (t.ex. "0000236-FO01-0001" för alla uppgifter utom personer med
|
||||
/// skyddade personuppgifter, eller "...-0002" för samtliga) samt ett
|
||||
/// test-organisationsnummer som Skatteverket tilldelar er i samband med
|
||||
/// godkännandet - inte något ni väljer själva.
|
||||
///
|
||||
/// 4. Navet hanterar personer med SKYDDADE PERSONUPPGIFTER särskilt -
|
||||
/// läs Skatteverkets information om det innan ni börjar, det påverkar
|
||||
/// vilken beställning (se punkt 3) ni får använda.
|
||||
///
|
||||
/// Tills dess returnerar denna klass alltid null och loggar en varning -
|
||||
/// den kraschar INTE resten av systemet, men ger heller ingen data.
|
||||
/// PatientService använder INTE detta ännu (se README, roadmap).
|
||||
/// </summary>
|
||||
public class NavetLookupService : INavetLookupService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<NavetLookupService> _logger;
|
||||
|
||||
public NavetLookupService(HttpClient httpClient, IConfiguration configuration, ILogger<NavetLookupService> logger)
|
||||
{
|
||||
_httpClient = httpClient; // BaseAddress = Navet:BaseUrl, se Program.cs
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<NavetLookupResult?> LookupByIdentifierAsync(string personalIdentityNumber)
|
||||
{
|
||||
var orderId = _configuration["Navet:OrderId"];
|
||||
var certificatePath = _configuration["Navet:ClientCertificatePath"];
|
||||
|
||||
if (string.IsNullOrEmpty(orderId) || string.IsNullOrEmpty(certificatePath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Navet-uppslag hoppat över: Navet:OrderId/Navet:ClientCertificatePath är inte konfigurerade. " +
|
||||
"Detta kräver godkänd ansökan hos Skatteverket - se kommentaren i NavetLookupService för detaljer.");
|
||||
return Task.FromResult<NavetLookupResult?>(null);
|
||||
}
|
||||
|
||||
// Implementationen här (mTLS-anrop mot Navets testtjänst med rätt
|
||||
// beställnings-ID) kan inte färdigställas eller testas förrän ni har
|
||||
// ett godkänt certifikat - se klasskommentaren. Lägg till den
|
||||
// faktiska HTTP-anropslogiken när ni har det.
|
||||
throw new NotSupportedException(
|
||||
"Navet-integrationen är förberedd men inte färdigimplementerad - den faktiska mTLS-anropslogiken " +
|
||||
"kräver ett riktigt certifikat och beställnings-ID att utveckla och testa mot.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Rinta.Infrastructure.ExternalApis;
|
||||
|
||||
/// <summary>
|
||||
/// Slår upp läkemedel mot openFDA Drug Label API (https://api.fda.gov/drug/label.json).
|
||||
///
|
||||
/// VIKTIGA BEGRÄNSNINGAR - läs innan detta används kliniskt:
|
||||
/// - openFDA innehåller FDA-godkända (amerikanska) läkemedel, inte den
|
||||
/// svenska marknaden. Ett preparat kan heta annorlunda, ha andra
|
||||
/// styrkor, eller helt enkelt inte finnas i Sverige.
|
||||
/// - Indikationstexten (`indications_and_usage`) är på engelska och skriven
|
||||
/// för amerikansk klinisk kontext/lagstiftning.
|
||||
/// - Detta är alltså ett tekniskt referensuppslag, INTE ett kliniskt
|
||||
/// beslutsstöd. Verklig svensk förskrivning bör stämmas av mot FASS,
|
||||
/// Läkemedelsverkets behandlingsrekommendationer, eller ett regionalt
|
||||
/// beslutsstöd (t.ex. Janusinfo).
|
||||
/// - Ingen API-nyckel krävs, men openFDA har en anonym rate limit på
|
||||
/// 240 anrop/minut och 1000/dag per IP. Sätt X-Api-Key om volymen växer
|
||||
/// (registrera på https://open.fda.gov/apis/authentication/).
|
||||
/// </summary>
|
||||
public class OpenFdaMedicationLookupService : IMedicationLookupService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public OpenFdaMedicationLookupService(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient; // BaseAddress = https://api.fda.gov/ sätts vid DI-registrering
|
||||
}
|
||||
|
||||
public async Task<List<MedicationLookupResult>> SearchByIndicationAsync(string diagnosisText, int limit = 10)
|
||||
{
|
||||
var query = $"indications_and_usage:\"{diagnosisText}\"";
|
||||
return await SearchAsync(query, limit);
|
||||
}
|
||||
|
||||
public async Task<List<MedicationLookupResult>> SearchByNameAsync(string drugName, int limit = 10)
|
||||
{
|
||||
var query = $"openfda.brand_name:\"{drugName}\"+openfda.generic_name:\"{drugName}\"";
|
||||
return await SearchAsync(query, limit, useOr: true);
|
||||
}
|
||||
|
||||
private async Task<List<MedicationLookupResult>> SearchAsync(string searchExpression, int limit, bool useOr = false)
|
||||
{
|
||||
var encoded = WebUtility.UrlEncode(useOr ? searchExpression.Replace("+", "+OR+") : searchExpression);
|
||||
var url = $"drug/label.json?search={encoded}&limit={Math.Clamp(limit, 1, 50)}";
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await _httpClient.GetAsync(url);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return new List<MedicationLookupResult>(); // extern källa nere - fall tillbaka på tom lista, inte krasch
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new List<MedicationLookupResult>();
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
using var doc = await JsonDocument.ParseAsync(stream);
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("results", out var results))
|
||||
return new List<MedicationLookupResult>();
|
||||
|
||||
var mapped = new List<MedicationLookupResult>();
|
||||
foreach (var result in results.EnumerateArray())
|
||||
{
|
||||
var openfda = result.TryGetProperty("openfda", out var of) ? of : default;
|
||||
|
||||
string? generic = GetFirstArrayValue(openfda, "generic_name");
|
||||
string? brand = GetFirstArrayValue(openfda, "brand_name");
|
||||
string? indications = GetFirstArrayValue(result, "indications_and_usage");
|
||||
string? setId = result.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
|
||||
|
||||
if (generic is null && brand is null) continue;
|
||||
|
||||
mapped.Add(new MedicationLookupResult(
|
||||
GenericName: generic ?? brand ?? "Okänt",
|
||||
BrandName: brand,
|
||||
IndicationsText: Truncate(indications, 1000),
|
||||
SourceApi: "openFDA",
|
||||
SourceRecordId: setId));
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
private static string? GetFirstArrayValue(JsonElement parent, string propertyName)
|
||||
{
|
||||
if (parent.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!parent.TryGetProperty(propertyName, out var prop)) return null;
|
||||
if (prop.ValueKind != JsonValueKind.Array || prop.GetArrayLength() == 0) return null;
|
||||
return prop[0].GetString();
|
||||
}
|
||||
|
||||
private static string? Truncate(string? text, int maxLength) =>
|
||||
text is null ? null : text.Length <= maxLength ? text : text[..maxLength] + "…";
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Rinta.Infrastructure.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Lokalt användarkonto (användarnamn/lösenord via ASP.NET Core Identity)
|
||||
/// istället för extern SSO (Keycloak/Authentik). Varje användare tillhör
|
||||
/// exakt en tenant (vårdgivare) - en användare kan alltså inte logga in
|
||||
/// och växla mellan flera vårdgivares data.
|
||||
///
|
||||
/// Roll (Clinician/Receptionist/Admin) hanteras via Identitys inbyggda
|
||||
/// roll-system (UserManager.AddToRoleAsync), inte som ett eget fält här.
|
||||
/// </summary>
|
||||
public class ApplicationUser : IdentityUser
|
||||
{
|
||||
public Guid TenantId { get; set; }
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Inaktiverade konton nekas inloggning även om lösenordet stämmer -
|
||||
/// se AccountController/Login. Skiljer sig från att ta bort kontot,
|
||||
/// vilket bevarar historiken (vem skapade/signerade vad).</summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Rinta.Infrastructure.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Lägger till en "tenant_id"-claim varje gång en användare loggar in eller
|
||||
/// dess principal återskapas från cookien. ITenantProvider läser sedan
|
||||
/// denna claim - se HttpTenantProvider. Detta är den enda platsen tenant_id
|
||||
/// sätts, vilket garanterar att den alltid kommer från servern (databasen),
|
||||
/// aldrig från något klienten skickar in.
|
||||
/// </summary>
|
||||
public class TenantClaimsPrincipalFactory : UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>
|
||||
{
|
||||
public TenantClaimsPrincipalFactory(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
RoleManager<IdentityRole> roleManager,
|
||||
IOptions<IdentityOptions> options)
|
||||
: base(userManager, roleManager, options)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user)
|
||||
{
|
||||
var identity = await base.GenerateClaimsAsync(user);
|
||||
identity.AddClaim(new Claim("tenant_id", user.TenantId.ToString()));
|
||||
identity.AddClaim(new Claim("display_name", user.DisplayName));
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Rinta.Domain\Rinta.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Rinta.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// AES-kryptering (använd för PersonalIdentityNumber) är medvetet
|
||||
/// slumpmässig per anrop (unik IV), vilket ger stark säkerhet men gör
|
||||
/// kolumnen omöjlig att söka eller kontrollera unikhet på direkt i SQL.
|
||||
/// Denna klass beräknar istället en DETERMINISTISK HMAC-SHA256-hash av
|
||||
/// samma normaliserade värde - samma indata ger alltid samma hash, vilket
|
||||
/// gör den indexerbar och jämförbar i databasen, utan att hashen i sig
|
||||
/// avslöjar personnumret (den kan bara jämföras, inte dekrypteras).
|
||||
/// </summary>
|
||||
public interface IIdentifierHasher
|
||||
{
|
||||
string Hash(string normalizedValue);
|
||||
}
|
||||
|
||||
public class HmacIdentifierHasher : IIdentifierHasher
|
||||
{
|
||||
private readonly byte[] _key;
|
||||
|
||||
public HmacIdentifierHasher(IConfiguration configuration)
|
||||
{
|
||||
var keyBase64 = configuration["Encryption:HashKey"]
|
||||
?? throw new InvalidOperationException("Encryption:HashKey saknas i konfigurationen.");
|
||||
_key = Convert.FromBase64String(keyBase64);
|
||||
}
|
||||
|
||||
public string Hash(string normalizedValue)
|
||||
{
|
||||
using var hmac = new HMACSHA256(_key);
|
||||
var bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(normalizedValue));
|
||||
return Convert.ToHexString(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Rinta.Domain.Common;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Rinta.Infrastructure.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Läser aktuell tenant/user ur den inloggade användarens claims. Sedan
|
||||
/// bytet från extern SSO till lokala konton (ASP.NET Core Identity +
|
||||
/// cookie-autentisering) sätts dessa claims av TenantClaimsPrincipalFactory
|
||||
/// vid inloggning, inte längre av en extern identity-leverantör.
|
||||
/// </summary>
|
||||
public class HttpTenantProvider : ITenantProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public HttpTenantProvider(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
public Guid GetCurrentTenantId()
|
||||
{
|
||||
var claim = _httpContextAccessor.HttpContext?.User?.FindFirst("tenant_id")?.Value;
|
||||
if (claim is null || !Guid.TryParse(claim, out var tenantId))
|
||||
throw new UnauthorizedAccessException("Ingen giltig tenant_id-claim hittades hos den inloggade användaren.");
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
public string GetCurrentUserId()
|
||||
{
|
||||
return _httpContextAccessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? throw new UnauthorizedAccessException("Ingen inloggad användare hittades.");
|
||||
}
|
||||
|
||||
public bool IsBreakGlassAccess()
|
||||
{
|
||||
// Sätts av ett separat, explicit API-anrop (se BreakGlassController,
|
||||
// ej inkluderad i denna scaffold) som kräver motivering i fritext
|
||||
// innan flaggan får sättas i sessionen/token.
|
||||
return _httpContextAccessor.HttpContext?.Request.Headers.ContainsKey("X-Break-Glass-Justification") == true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
using Rinta.Domain.Common;
|
||||
using Rinta.Domain.Entities;
|
||||
using Rinta.Infrastructure.Data;
|
||||
using Rinta.Infrastructure.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Rinta.Infrastructure.Services;
|
||||
|
||||
public record CreatePatientCommand(
|
||||
string? PersonalIdentityNumber,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateOnly DateOfBirth,
|
||||
string? Sex,
|
||||
string? PhoneNumber,
|
||||
string? Email,
|
||||
string? Address,
|
||||
bool HasProtectedIdentity
|
||||
);
|
||||
|
||||
public record PatientServiceResult(Patient? Patient, string? ErrorMessage)
|
||||
{
|
||||
public bool Succeeded => Patient is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kärnlogiken för att registrera en patient (Luhn-validering, generering
|
||||
/// av reservnummer, dubblettkontroll via hash). Ligger i Infrastructure så
|
||||
/// att både JSON-API:t (PatientsController) och Blazor-UI:t
|
||||
/// (Components/Pages/PatientCreate.razor) använder EXAKT samma regler -
|
||||
/// annars är risken stor att de glider isär över tid.
|
||||
/// </summary>
|
||||
public class PatientService
|
||||
{
|
||||
private readonly JournalDbContext _db;
|
||||
private readonly ITenantProvider _tenantProvider;
|
||||
private readonly IIdentifierHasher _identifierHasher;
|
||||
|
||||
public PatientService(JournalDbContext db, ITenantProvider tenantProvider, IIdentifierHasher identifierHasher)
|
||||
{
|
||||
_db = db;
|
||||
_tenantProvider = tenantProvider;
|
||||
_identifierHasher = identifierHasher;
|
||||
}
|
||||
|
||||
public async Task<PatientServiceResult> CreateAsync(CreatePatientCommand command)
|
||||
{
|
||||
string identifier;
|
||||
SwedishIdentifierType identityType;
|
||||
bool isConfirmed;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(command.PersonalIdentityNumber))
|
||||
{
|
||||
var sameDayCount = await _db.Patients.CountAsync(p =>
|
||||
p.IdentityType == SwedishIdentifierType.ReservationNumber
|
||||
&& p.DateOfBirth == command.DateOfBirth);
|
||||
|
||||
identifier = ReservationNumberGenerator.Generate(command.DateOfBirth, sameDayCount);
|
||||
identityType = SwedishIdentifierType.ReservationNumber;
|
||||
isConfirmed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!PersonnummerValidator.TryValidate(command.PersonalIdentityNumber, out identityType, out var normalized))
|
||||
return new PatientServiceResult(null,
|
||||
"Personnumret/samordningsnumret klarar inte Luhn-kontrollen eller har fel format (ÅÅÅÅMMDD-XXXX). " +
|
||||
"Lämna fältet tomt istället om identiteten inte är känd - då registreras patienten med reservnummer.");
|
||||
|
||||
identifier = normalized;
|
||||
isConfirmed = true;
|
||||
|
||||
var hash = _identifierHasher.Hash(identifier);
|
||||
var duplicateExists = await _db.Patients.AnyAsync(p => p.PersonalIdentityNumberHash == hash);
|
||||
if (duplicateExists)
|
||||
return new PatientServiceResult(null, "En patient med detta personnummer/samordningsnummer är redan registrerad.");
|
||||
}
|
||||
|
||||
var patient = new Patient
|
||||
{
|
||||
TenantId = _tenantProvider.GetCurrentTenantId(),
|
||||
CreatedByUserId = _tenantProvider.GetCurrentUserId(),
|
||||
PersonalIdentityNumber = identifier,
|
||||
PersonalIdentityNumberHash = _identifierHasher.Hash(identifier),
|
||||
IdentityType = identityType,
|
||||
IsIdentityConfirmed = isConfirmed,
|
||||
FirstName = command.FirstName,
|
||||
LastName = command.LastName,
|
||||
DateOfBirth = command.DateOfBirth,
|
||||
Sex = command.Sex,
|
||||
PhoneNumber = command.PhoneNumber,
|
||||
Email = command.Email,
|
||||
Address = command.Address,
|
||||
HasProtectedIdentity = command.HasProtectedIdentity
|
||||
};
|
||||
|
||||
_db.Patients.Add(patient);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return new PatientServiceResult(patient, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
Write-Host "Startar Rinta (server + klient)..." -ForegroundColor Cyan
|
||||
|
||||
$root = $PSScriptRoot
|
||||
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$root'; dotnet run --project src/Rinta.Api --launch-profile http"
|
||||
|
||||
Write-Host "Väntar 5 sekunder på att servern ska hinna starta..." -ForegroundColor Yellow
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$root'; dotnet run --project src/Rinta.Desktop"
|
||||
|
||||
Write-Host "Klart. Kolla serverfönstret för admin-lösenordet vid första körningen." -ForegroundColor Green
|
||||
Reference in New Issue
Block a user