Initial commit

This commit is contained in:
bertinaabadmin
2026-07-30 11:43:44 +02:00
commit e2085f2dbe
70 changed files with 4869 additions and 0 deletions
+31
View File
@@ -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
+222
View File
@@ -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.
+33
View File
@@ -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
+28
View File
@@ -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();
}
}
+39
View File
@@ -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
);
+26
View File
@@ -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
);
+35
View File
@@ -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
);
+52
View File
@@ -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
);
+55
View File
@@ -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
);
+160
View File
@@ -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"
}
}
}
}
+19
View File
@@ -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>
+24
View File
@@ -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": ""
}
}
+145
View File
@@ -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>
+45
View File
@@ -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

+97
View File
@@ -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

+132
View File
@@ -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
);
+19
View File
@@ -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>
+286
View File
@@ -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&lt;string&gt; 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();
}
}
+40
View File
@@ -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();
}
}
+3
View File
@@ -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();
}
+42
View File
@@ -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; }
}
+52
View File
@@ -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;
}
+49
View File
@@ -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>();
}
+56
View File
@@ -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; }
}
+17
View File
@@ -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;
}
+7
View File
@@ -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);
}
}
+12
View File
@@ -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