commit 5e316042a3e3eccdbf7a33c2d2319c6abbf6c473
Author: Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
AuthorDate: Tue Sep 1 09:45:58 2026 +0200
Commit: Jens Kristoffersson <jens.kristoffersson.se@gmail.com>
CommitDate: Tue Sep 1 09:45:58 2026 +0200
Scaffolda Fas 1: fristående remiss-app (WinForms)
Solution med Remiss.Core (logik) + Remiss.App (WinForms-exe).
Komponenter: entrypoint med argumenthantering, mottagningsregister
bakom IMottagningRepository (JSON-POC), sökbar mottagningsdialog,
försättsbladsgenerator (MigraDoc), PDF-merge (PdfSharp), tyst utskrift
via SumatraPDF med retry, audit-logg (metadata) + separat error-logg,
samt ACL-låst och shreddande SecureTempStore för patientdata.
Kräver .NET 8 SDK (ej installerat än) och SumatraPDF-binär i tools/.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
.gitignore | 29 +++++
Plan.md | 35 ++++++
README.md | 100 ++++++++++++++++
Remiss.sln | 27 +++++
config/appsettings.json | 11 ++
config/mottagningar.json | 8 ++
src/Remiss.App/MottagningDialog.cs | 126 +++++++++++++++++++++
src/Remiss.App/Program.cs | 113 ++++++++++++++++++
src/Remiss.App/Remiss.App.csproj | 28 +++++
.../Abstractions/IMottagningRepository.cs | 14 +++
src/Remiss.Core/Abstractions/IPrinter.cs | 10 ++
src/Remiss.Core/AppConfig.cs | 65 +++++++++++
src/Remiss.Core/Models/Mottagning.cs | 10 ++
src/Remiss.Core/Pdf/CoverPageGenerator.cs | 55 +++++++++
src/Remiss.Core/Pdf/PdfMerger.cs | 44 +++++++
src/Remiss.Core/Printing/SumatraPdfPrinter.cs | 83 ++++++++++++++
src/Remiss.Core/Remiss.Core.csproj | 20 ++++
src/Remiss.Core/RemissWorkflow.cs | 57 ++++++++++
.../Repositories/JsonMottagningRepository.cs | 34 ++++++
src/Remiss.Core/Security/SecureFile.cs | 39 +++++++
src/Remiss.Core/Security/SecureTempStore.cs | 75 ++++++++++++
tools/README.md | 26 +++++
22 files changed, 1009 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6b44f5f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,29 @@
+# .NET
+bin/
+obj/
+*.user
+*.suo
+.vs/
+
+# Build output
+[Dd]ebug/
+[Rr]elease/
+artifacts/
+
+# Rider / VS Code
+.idea/
+.vscode/
+
+# Bundled third-party binaries (fetched separately, see tools/README.md)
+tools/*.exe
+tools/*.dll
+tools/*.zip
+
+# Local runtime data / logs (never commit – may contain paths to patient data)
+*.log
+audit/
+*.jsonl
+
+# OS
+Thumbs.db
+desktop.ini
diff --git a/Plan.md b/Plan.md
new file mode 100644
index 0000000..74349ee
--- /dev/null
+++ b/Plan.md
@@ -0,0 +1,35 @@
+Arkitektur
+
+Plain Text
+Användare trycker Skriv ut
+ → Skrivare "Remiss" (fångstlager, t.ex. clawPDF)
+ → PS→PDF, anropar ditt C#-exe med sökväg till PDF:en
+ → [1] Dialog: välj mottagning
+ → [2] Slå upp HSA-id
+ → [3] Generera försättsblad
+ → [4] Merge försättsblad + remiss
+ → [5] Tyst utskrift till "andra digitala skrivaren"
+ → [6] Logga + städa temp-fil
+Fångstlagret (clawPDF) gör bara det tråkiga: registrerar en skrivare som heter "Remiss", konverterar PostScript till PDF och kör ditt exe med PDF-sökvägen som argument. Klart. Resten är din kod.
+Teknikval (och licensfällorna, för de är viktiga här)
+.NET 8, Windows-only.
+UI: WPF för mottagningsdialogen — native, inga extra runtime-bekymmer. (Avalonia funkar och ligger i linje med det du redan pillar med, men för ett Windows-lokalt printverktyg tillför det inget här.)
+PDF-generering + merge: PdfSharp/MigraDoc 6.x — MIT-licens, gratis. Detta är default just för att slippa skit. Undvik QuestPDF trots att API:et är snyggare: Community-licensen är gratis bara under ~1 M USD omsättning, vilket VGR garanterat spränger — då krävs betald licens. Undvik iText (AGPL/kommersiell). PdfSharp täcker både försättsblad och sammanfogning utan en krona.
+Tyst utskrift till målskrivaren: SumatraPDF (-print-to "Skrivarnamn" -silent) buntad med appen. Mest pålitliga vägen att skjuta en PDF till en namngiven skrivare utan dialoger. Alternativ: gsprint (Ghostscript).
+Komponenter att bygga
+Entrypoint — tar emot PDF-sökväg som argument, felhanterar om filen saknas.
+Mottagningsregister — POC: mottagningar.json (namn → HSA-id). Prod: dra mot HSA-katalogen. Håll det bakom ett interface så du kan byta källa utan att röra resten.
+Mottagningsdialog (WPF) — sökbar lista, tydlig knapp, avbryt-hantering (vad händer om användaren stänger utan att välja? → avbryt jobbet, städa).
+Försättsbladsgenerator — "Remiss", mottagningens namn + HSA-id, stor text "Remiss bifogad som sida 2". Layouten här är inte spikad förrän Fas 0 nedan är löst.
+Merge — försättsblad som sida 1, remissen efter.
+Utskriftsmodul — skickar mergad PDF till målskrivaren, kollar returkod, retry-logik om det fallerar.
+Logg/audit — vem skickade vilken remiss till vilken mottagning, när, och lyckades det. Men logga aldrig patientdata eller innehåll — bara metadata. Det här är remisser, alltså känsliga personuppgifter.
+Säkerhet — måste in från början, inte klistras på sen
+Temp-PDF:erna innehåller patientdata. Det betyder: skriv dem till en skyddad temp-mapp (inte %TEMP% med slappa rättigheter), radera direkt efter lyckad utskrift, och håll dem ur alla loggar och crashdumps. Om sändningen fallerar — vad händer med filen? Den får inte bli liggande. Din GDPR/DPIA-erfarenhet lär göra det här till en av de första grejerna du vill nagla fast.
+Faser
+Fas 0 — Spika nedströms. Blockerande för Fas 4/försättsbladets format: hur läser den andra digitala skrivaren av vart remissen ska? Text, streckkod, eller separat metadatafil? Tills det är känt bygger vi mot en antagen layout men vet att den kan behöva göras om.
+Fas 1 — POC av C#-appen fristående. Kör den manuellt med en test-PDF som argument. Dialog → försättsblad → merge → skriv ut till valfri testskrivare. Ingen fångst inblandad än. Här får du 80% av värdet och kan validera flödet snabbt.
+Fas 2 — Koppla på fångst. Installera clawPDF, skapa skrivaren "Remiss", peka den mot ditt exe. Nu funkar hela kedjan från "Skriv ut".
+Fas 3 — Härda. HSA-källa, config, audit-logg, felhantering, temp-filsäkerhet, avbrottsflöden.
+Fas 4 — Paketera & rulla ut. Kodsignering, tyst installation av clahPDF + config + skrivarprovisionering, distribution via Intune/SCCM, pilot på en mottagning innan bredare.
+Föreslår att vi kör igång på Fas 1 direkt — den är oberoende av allt strul kring fångst och nedströms, och ger dig något att testa i handen. Ska jag scaffolda C#-projektet (solution-struktur, dialog, försättsblad, merge, utskrift) så du har en körbar grund? Säg bara till om du vill ha WPF eller WinForms för dialogen — WinForms om du vill ha det så lätt som möjligt, WPF om det ska leva vidare.
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..11a5f83
--- /dev/null
+++ b/README.md
@@ -0,0 +1,100 @@
+# Remiss – automatiskt försättsblad vid utskrift
+
+När en användare skriver ut en remiss fångas jobbet av en PDF-skrivare ("Remiss"),
+den här appen får PDF:en som argument, låter användaren välja mottagning, genererar
+ett försättsblad, sammanfogar det med remissen och skickar allt tyst till
+måldigitalskrivaren.
+
+Se [Plan.md](Plan.md) för arkitektur, teknikval och faser.
+
+## Status
+
+| Fas | Innehåll | Läge |
+|-----|----------|------|
+| 0 | Spika hur nedströms-skrivaren läser av mottagning | **Blockerad – ej påbörjad** |
+| 1 | Fristående C#-app, körs manuellt med test-PDF | **Scaffold klart – detta repo** |
+| 2 | Koppla på fångst (clawPDF) | Ej påbörjad |
+| 3 | Härdning (HSA, config, audit, temp, felflöden) | Delvis förberedd i koden |
+| 4 | Paketering & utrullning | Ej påbörjad |
+
+## Förutsättningar
+
+- Windows
+- **.NET 8 SDK** – finns _inte_ på den här maskinen ännu (bara runtimes).
+ Installera via Software Center eller <https://aka.ms/dotnet/download>.
+- SumatraPDF-binär i `tools/` – se [tools/README.md](tools/README.md).
+ Behövs bara för det sista utskriftssteget; dialog + försättsblad + merge
+ fungerar utan.
+
+## Bygga och köra (Fas 1)
+
+```powershell
+dotnet build Remiss.sln
+
+# Kör mot en test-PDF och en testskrivare
+dotnet run --project src/Remiss.App -- "C:\temp\testremiss.pdf"
+```
+
+Målskrivaren styrs av `targetPrinter` i `config/appsettings.json`
+(default: "Microsoft Print to PDF" – byt till en riktig skrivare vid test).
+
+Utan SumatraPDF-binär kommer stegen dialog → försättsblad → merge att köras,
+och utskriftssteget kastar ett tydligt fel.
+
+## Projektstruktur
+
+```
+Remiss.sln
+config/
+ appsettings.json sökvägar, målskrivare, retry, flaggor
+ mottagningar.json POC-register (namn → HSA-id)
+tools/ buntad SumatraPDF (checkas ej in)
+src/
+ Remiss.Core/ all logik, UI-oberoende
+ Abstractions/ IMottagningRepository, IPrinter
+ Models/ Mottagning
+ Repositories/ JsonMottagningRepository
+ Pdf/ CoverPageGenerator (MigraDoc), PdfMerger (PdfSharp)
+ Printing/ SumatraPdfPrinter (retry)
+ Security/ SecureTempStore (ACL + shred), SecureFile
+ Audit/ AuditLogger (JSONL + separat error-logg)
+ AppConfig.cs konfigmodell + inläsning
+ RemissWorkflow.cs steg [3]–[6]
+ Remiss.App/ WinForms-exe (AssemblyName: Remiss)
+ Program.cs entrypoint, argument, returkoder
+ MottagningDialog.cs sökbar lista, avbryt-hantering
+```
+
+## Konfiguration (`config/appsettings.json`)
+
+| Nyckel | Betydelse |
+|--------|-----------|
+| `targetPrinter` | Namn på måldigitalskrivaren |
+| `sumatraPdfPath` | Sökväg till SumatraPDF.exe (relativt exe-mappen) |
+| `mottagningarPath` | Sökväg till mottagningsregistret |
+| `secureTempRoot` | Tom = `%LOCALAPPDATA%\Remiss\temp` |
+| `auditLogPath` | Tom = `%LOCALAPPDATA%\Remiss\audit\audit.jsonl` |
+| `errorLogPath` | Tom = `%LOCALAPPDATA%\Remiss\logs\error.log` |
+| `printRetries` / `printRetryDelayMs` | Retry vid utskriftsfel |
+| `deleteSourceAfterPrint` | `false` i Fas 1 (behåll test-PDF:en). `true` i Fas 2. |
+
+## Säkerhet
+
+Remiss-PDF:erna är känsliga personuppgifter.
+
+- Temp-filer skrivs till en jobb-isolerad mapp under användarprofilen (inte `%TEMP%`),
+ med ACL låst till användaren + SYSTEM + Administrators.
+- `SecureTempStore.Dispose()` skriver över och raderar alla temp-filer – körs även
+ när utskriften fallerar.
+- **Audit-loggen** innehåller bara metadata: användare, maskin, mottagning (namn +
+ HSA-id), tidpunkt, utfall. Aldrig patientdata, filnamn eller innehåll.
+- **Error-loggen** kan innehålla sökvägar och stacktraces. Den ligger användar-scopat
+ och ska redigeras/rensas i Fas 3-härdningen.
+
+## Kända begränsningar (Fas 1)
+
+- Försättsbladets layout är preliminär tills Fas 0 är löst.
+- Mottagningsregistret är statisk JSON; HSA-katalog kopplas i Fas 3 bakom
+ `IMottagningRepository`.
+- Inga automatiska tester än.
+- SecureFile.Shred är bäst-möjligt, inte forensiskt (SSD/wear-leveling).
diff --git a/Remiss.sln b/Remiss.sln
new file mode 100644
index 0000000..9c4e2b0
--- /dev/null
+++ b/Remiss.sln
@@ -0,0 +1,27 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Remiss.Core", "src\Remiss.Core\Remiss.Core.csproj", "{11111111-1111-4111-8111-111111111111}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Remiss.App", "src\Remiss.App\Remiss.App.csproj", "{22222222-2222-4222-8222-222222222222}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {11111111-1111-4111-8111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11111111-1111-4111-8111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {11111111-1111-4111-8111-111111111111}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {11111111-1111-4111-8111-111111111111}.Release|Any CPU.Build.0 = Release|Any CPU
+ {22222222-2222-4222-8222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {22222222-2222-4222-8222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {22222222-2222-4222-8222-222222222222}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {22222222-2222-4222-8222-222222222222}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/config/appsettings.json b/config/appsettings.json
new file mode 100644
index 0000000..0845cfd
--- /dev/null
+++ b/config/appsettings.json
@@ -0,0 +1,11 @@
+{
+ "targetPrinter": "Microsoft Print to PDF",
+ "sumatraPdfPath": "tools/SumatraPDF.exe",
+ "mottagningarPath": "mottagningar.json",
+ "secureTempRoot": "",
+ "auditLogPath": "",
+ "errorLogPath": "",
+ "printRetries": 3,
+ "printRetryDelayMs": 2000,
+ "deleteSourceAfterPrint": false
+}
diff --git a/config/mottagningar.json b/config/mottagningar.json
new file mode 100644
index 0000000..24a9270
--- /dev/null
+++ b/config/mottagningar.json
@@ -0,0 +1,8 @@
+[
+ { "namn": "Hudmottagningen Sahlgrenska", "hsaId": "SE2321000131-E000000000001" },
+ { "namn": "Ögonmottagningen Mölndal", "hsaId": "SE2321000131-E000000000002" },
+ { "namn": "Ortopedmottagningen Östra", "hsaId": "SE2321000131-E000000000003" },
+ { "namn": "Kardiologmottagningen SU", "hsaId": "SE2321000131-E000000000004" },
+ { "namn": "Öron-näsa-hals Kungälv", "hsaId": "SE2321000131-E000000000005" },
+ { "namn": "Kirurgmottagningen Borås", "hsaId": "SE2321000131-E000000000006" }
+]
diff --git a/src/Remiss.App/MottagningDialog.cs b/src/Remiss.App/MottagningDialog.cs
new file mode 100644
index 0000000..6121c57
--- /dev/null
+++ b/src/Remiss.App/MottagningDialog.cs
@@ -0,0 +1,126 @@
+using System.Drawing;
+using System.Windows.Forms;
+using Remiss.Core.Models;
+
+namespace Remiss.App;
+
+/// <summary>
+/// Steg [1]: sökbar lista över mottagningar. Byggd helt i kod – ingen designer.
+/// Avbryt (Esc / stäng / knapp) ger <see cref="DialogResult.Cancel"/> och
+/// <see cref="SelectedMottagning"/> = null, vilket anroparen tolkar som "städa och avsluta".
+/// </summary>
+public sealed class MottagningDialog : Form
+{
+ private readonly IReadOnlyList<Mottagning> _all;
+ private readonly TextBox _search = new();
+ private readonly ListBox _list = new();
+ private readonly Button _ok = new();
+ private readonly Button _cancel = new();
+
+ public Mottagning? SelectedMottagning { get; private set; }
+
+ public MottagningDialog(IReadOnlyList<Mottagning> mottagningar)
+ {
+ _all = mottagningar;
+
+ Text = "Välj mottagning";
+ StartPosition = FormStartPosition.CenterScreen;
+ FormBorderStyle = FormBorderStyle.Sizable;
+ MinimizeBox = false;
+ MaximizeBox = false;
+ ShowInTaskbar = true;
+ MinimumSize = new Size(420, 360);
+ ClientSize = new Size(460, 520);
+
+ var layout = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 1,
+ RowCount = 3,
+ Padding = new Padding(12),
+ };
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 30));
+ layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
+ layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48));
+
+ _search.Dock = DockStyle.Fill;
+ _search.PlaceholderText = "Sök mottagning eller HSA-id…";
+ _search.TextChanged += (_, _) => ApplyFilter();
+ _search.KeyDown += SearchKeyDown;
+
+ _list.Dock = DockStyle.Fill;
+ _list.IntegralHeight = false;
+ _list.DoubleClick += (_, _) => TryAccept();
+ _list.SelectedIndexChanged += (_, _) => _ok.Enabled = _list.SelectedItem is Mottagning;
+
+ _ok.Text = "OK";
+ _ok.Enabled = false;
+ _ok.AutoSize = true;
+ _ok.Padding = new Padding(12, 4, 12, 4);
+ _ok.Click += (_, _) => TryAccept();
+
+ _cancel.Text = "Avbryt";
+ _cancel.AutoSize = true;
+ _cancel.Padding = new Padding(12, 4, 12, 4);
+ _cancel.DialogResult = DialogResult.Cancel;
+
+ var buttons = new FlowLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ FlowDirection = FlowDirection.RightToLeft,
+ };
+ buttons.Controls.Add(_cancel);
+ buttons.Controls.Add(_ok);
+
+ layout.Controls.Add(_search, 0, 0);
+ layout.Controls.Add(_list, 0, 1);
+ layout.Controls.Add(buttons, 0, 2);
+ Controls.Add(layout);
+
+ AcceptButton = _ok;
+ CancelButton = _cancel;
+
+ ApplyFilter();
+ }
+
+ private void ApplyFilter()
+ {
+ var query = _search.Text.Trim();
+
+ IEnumerable<Mottagning> matches = string.IsNullOrEmpty(query)
+ ? _all
+ : _all.Where(m =>
+ m.Namn.Contains(query, StringComparison.CurrentCultureIgnoreCase) ||
+ m.HsaId.Contains(query, StringComparison.OrdinalIgnoreCase));
+
+ _list.BeginUpdate();
+ _list.Items.Clear();
+ foreach (var m in matches)
+ _list.Items.Add(m);
+ _list.EndUpdate();
+
+ if (_list.Items.Count > 0)
+ _list.SelectedIndex = 0;
+
+ _ok.Enabled = _list.SelectedItem is Mottagning;
+ }
+
+ private void SearchKeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.KeyCode == Keys.Down && _list.Items.Count > 0)
+ {
+ _list.Focus();
+ e.Handled = true;
+ }
+ }
+
+ private void TryAccept()
+ {
+ if (_list.SelectedItem is not Mottagning m)
+ return;
+
+ SelectedMottagning = m;
+ DialogResult = DialogResult.OK;
+ Close();
+ }
+}
diff --git a/src/Remiss.App/Program.cs b/src/Remiss.App/Program.cs
new file mode 100644
index 0000000..c85c44b
--- /dev/null
+++ b/src/Remiss.App/Program.cs
@@ -0,0 +1,113 @@
+using System.Windows.Forms;
+using Remiss.Core;
+using Remiss.Core.Audit;
+using Remiss.Core.Models;
+using Remiss.Core.Printing;
+using Remiss.Core.Repositories;
+using Remiss.Core.Security;
+
+namespace Remiss.App;
+
+/// <summary>
+/// Entrypoint. Anropas av fångstlagret (Fas 2) eller manuellt (Fas 1) med
+/// sökväg till remiss-PDF:en som argument.
+///
+/// Returkoder:
+/// 0 klart och utskrivet
+/// 1 fel under generering/merge/utskrift (temp städat)
+/// 2 ogiltigt/missande PDF-argument
+/// 3 användaren avbröt i dialogen
+/// 10 konfigurationsfel
+/// 11 fel i mottagningsregistret
+/// </summary>
+internal static class Program
+{
+ [STAThread]
+ private static int Main(string[] args)
+ {
+ ApplicationConfiguration.Initialize();
+
+ var baseDir = AppContext.BaseDirectory;
+
+ AppConfig config;
+ AuditLogger audit;
+ try
+ {
+ config = AppConfig.Load(baseDir);
+ audit = new AuditLogger(config.AuditLogPath, config.ErrorLogPath);
+ }
+ catch (Exception ex)
+ {
+ ShowError("Konfigurationsfel", ex.Message);
+ return 10;
+ }
+
+ // [0] Ta emot och validera PDF-sökvägen.
+ var sourceArg = args.FirstOrDefault(a => !a.StartsWith('-'));
+ if (string.IsNullOrWhiteSpace(sourceArg))
+ {
+ audit.Write("job.rejected", null, null, success: false, stage: "args", detail: "missing-path");
+ ShowError("Fel", "Ingen PDF-sökväg angavs som argument.");
+ return 2;
+ }
+
+ var sourcePdf = Path.GetFullPath(sourceArg);
+ if (!File.Exists(sourcePdf))
+ {
+ audit.Write("job.rejected", null, null, success: false, stage: "args", detail: "file-not-found");
+ ShowError("Fel", $"Filen finns inte:\n{sourcePdf}");
+ return 2;
+ }
+
+ // [2] Läs mottagningsregistret.
+ IReadOnlyList<Mottagning> mottagningar;
+ try
+ {
+ mottagningar = new JsonMottagningRepository(config.MottagningarPath).GetAll();
+ }
+ catch (Exception ex)
+ {
+ audit.Write("job.rejected", null, null, success: false, stage: "registry", detail: ex.GetType().Name);
+ audit.Error("registry", ex);
+ ShowError("Fel", $"Kunde inte läsa mottagningsregistret:\n{ex.Message}");
+ return 11;
+ }
+
+ if (mottagningar.Count == 0)
+ {
+ ShowError("Fel", "Mottagningsregistret är tomt.");
+ return 11;
+ }
+
+ // [1] Dialog: välj mottagning.
+ Mottagning selected;
+ using (var dialog = new MottagningDialog(mottagningar))
+ {
+ if (dialog.ShowDialog() != DialogResult.OK || dialog.SelectedMottagning is null)
+ {
+ audit.Write("job.cancelled", null, null, success: false, stage: "dialog");
+ return 3; // inget temp skapat än – inget att städa
+ }
+
+ selected = dialog.SelectedMottagning;
+ }
+
+ // [3]–[6] Generera, merge, skriv ut, logga. Temp städas alltid.
+ using var temp = new SecureTempStore(config.SecureTempRoot);
+ try
+ {
+ var printer = new SumatraPdfPrinter(config.SumatraPdfPath, config.PrintRetries, config.PrintRetryDelayMs);
+ new RemissWorkflow(config, printer, audit).Run(sourcePdf, selected, temp);
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ ShowError("Utskrift misslyckades",
+ $"Remissen till {selected.Namn} kunde inte skickas.\n\n{ex.Message}\n\nTemporära filer har raderats.");
+ return 1;
+ }
+ }
+
+ private static void ShowError(string title, string message) =>
+ MessageBox.Show(message, title, MessageBoxButtons.OK, MessageBoxIcon.Error);
+}
diff --git a/src/Remiss.App/Remiss.App.csproj b/src/Remiss.App/Remiss.App.csproj
new file mode 100644
index 0000000..446c035
--- /dev/null
+++ b/src/Remiss.App/Remiss.App.csproj
@@ -0,0 +1,28 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>WinExe</OutputType>
+ <TargetFramework>net8.0-windows</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <UseWindowsForms>true</UseWindowsForms>
+ <LangVersion>latest</LangVersion>
+ <RootNamespace>Remiss.App</RootNamespace>
+ <AssemblyName>Remiss</AssemblyName>
+ <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\Remiss.Core\Remiss.Core.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Include="..\..\config\appsettings.json" Link="appsettings.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ <None Include="..\..\config\mottagningar.json" Link="mottagningar.json">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+</Project>
diff --git a/src/Remiss.Core/Abstractions/IMottagningRepository.cs b/src/Remiss.Core/Abstractions/IMottagningRepository.cs
new file mode 100644
index 0000000..dc51944
--- /dev/null
+++ b/src/Remiss.Core/Abstractions/IMottagningRepository.cs
@@ -0,0 +1,14 @@
+using Remiss.Core.Models;
+
+namespace Remiss.Core.Abstractions;
+
+/// <summary>
+/// Källa för mottagningsregistret. Håll allt bakom det här interfacet så att
+/// JSON-POC:en kan bytas mot HSA-katalogen utan att röra resten av appen.
+/// </summary>
+public interface IMottagningRepository
+{
+ IReadOnlyList<Mottagning> GetAll();
+
+ Mottagning? FindByHsaId(string hsaId);
+}
diff --git a/src/Remiss.Core/Abstractions/IPrinter.cs b/src/Remiss.Core/Abstractions/IPrinter.cs
new file mode 100644
index 0000000..4158365
--- /dev/null
+++ b/src/Remiss.Core/Abstractions/IPrinter.cs
@@ -0,0 +1,10 @@
+namespace Remiss.Core.Abstractions;
+
+/// <summary>
+/// Skickar en färdig PDF tyst till en namngiven skrivare. Kastar vid fel så att
+/// arbetsflödet kan logga och visa felruta – temp-städningen sker ändå.
+/// </summary>
+public interface IPrinter
+{
+ void Print(string pdfPath, string printerName);
+}
diff --git a/src/Remiss.Core/AppConfig.cs b/src/Remiss.Core/AppConfig.cs
new file mode 100644
index 0000000..5a9ff6d
--- /dev/null
+++ b/src/Remiss.Core/AppConfig.cs
@@ -0,0 +1,65 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Remiss.Core;
+
+/// <summary>
+/// Läses från <c>appsettings.json</c> bredvid exe:n. Relativa sökvägar tolkas
+/// mot exe-mappen; tomma logg-/temp-sökvägar defaultas under LOCALAPPDATA\Remiss.
+/// </summary>
+public sealed class AppConfig
+{
+ public string TargetPrinter { get; set; } = "";
+ public string SumatraPdfPath { get; set; } = "tools/SumatraPDF.exe";
+ public string MottagningarPath { get; set; } = "mottagningar.json";
+ public string SecureTempRoot { get; set; } = "";
+ public string AuditLogPath { get; set; } = "";
+ public string ErrorLogPath { get; set; } = "";
+ public int PrintRetries { get; set; } = 3;
+ public int PrintRetryDelayMs { get; set; } = 2000;
+ public bool DeleteSourceAfterPrint { get; set; }
+
+ [JsonIgnore]
+ public string ConfigFilePath { get; private set; } = "";
+
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ public static AppConfig Load(string baseDir)
+ {
+ var path = Path.Combine(baseDir, "appsettings.json");
+
+ var cfg = File.Exists(path)
+ ? JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(path), Options) ?? new AppConfig()
+ : new AppConfig();
+
+ cfg.ConfigFilePath = path;
+ cfg.Resolve(baseDir);
+ return cfg;
+ }
+
+ private void Resolve(string baseDir)
+ {
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var appRoot = Path.Combine(localAppData, "Remiss");
+
+ SumatraPdfPath = ToAbsolute(SumatraPdfPath, baseDir);
+ MottagningarPath = ToAbsolute(MottagningarPath, baseDir);
+
+ if (string.IsNullOrWhiteSpace(SecureTempRoot))
+ SecureTempRoot = Path.Combine(appRoot, "temp");
+
+ if (string.IsNullOrWhiteSpace(AuditLogPath))
+ AuditLogPath = Path.Combine(appRoot, "audit", "audit.jsonl");
+
+ if (string.IsNullOrWhiteSpace(ErrorLogPath))
+ ErrorLogPath = Path.Combine(appRoot, "logs", "error.log");
+ }
+
+ private static string ToAbsolute(string path, string baseDir) =>
+ Path.IsPathRooted(path) ? path : Path.GetFullPath(Path.Combine(baseDir, path));
+}
diff --git a/src/Remiss.Core/Models/Mottagning.cs b/src/Remiss.Core/Models/Mottagning.cs
new file mode 100644
index 0000000..520cc92
--- /dev/null
+++ b/src/Remiss.Core/Models/Mottagning.cs
@@ -0,0 +1,10 @@
+namespace Remiss.Core.Models;
+
+/// <summary>
+/// En mottagning som en remiss kan skickas till. POC-källan är
+/// <c>mottagningar.json</c>; prod drar mot HSA-katalogen bakom samma interface.
+/// </summary>
+public sealed record Mottagning(string Namn, string HsaId)
+{
+ public override string ToString() => $"{Namn} · {HsaId}";
+}
diff --git a/src/Remiss.Core/Pdf/CoverPageGenerator.cs b/src/Remiss.Core/Pdf/CoverPageGenerator.cs
new file mode 100644
index 0000000..98e2bce
--- /dev/null
+++ b/src/Remiss.Core/Pdf/CoverPageGenerator.cs
@@ -0,0 +1,55 @@
+using MigraDoc.DocumentObjectModel;
+using MigraDoc.Rendering;
+
+namespace Remiss.Core.Pdf;
+
+/// <summary>Uppgifter som ska stå på försättsbladet. Ingen patientdata här.</summary>
+public sealed record CoverInfo(string MottagningNamn, string HsaId, DateTime Skapad);
+
+/// <summary>
+/// Genererar försättsbladet (sida 1). Layouten är preliminär – den spikas först
+/// när Fas 0 (hur nedströms-skrivaren läser av mottagning) är löst.
+/// </summary>
+public static class CoverPageGenerator
+{
+ public static void Generate(string outputPath, CoverInfo info)
+ {
+ // PdfSharp 6.1 har en inbyggd font-resolver för Windows – ingen manuell
+ // GlobalFontSettings.FontResolver behövs så länge appen är Windows-only.
+ var doc = new Document();
+ doc.Info.Title = "Remiss – försättsblad";
+
+ var section = doc.AddSection();
+ section.PageSetup.PageFormat = PageFormat.A4;
+ section.PageSetup.TopMargin = Unit.FromCentimeter(3);
+ section.PageSetup.LeftMargin = Unit.FromCentimeter(2.5);
+ section.PageSetup.RightMargin = Unit.FromCentimeter(2.5);
+
+ var heading = section.AddParagraph("REMISS");
+ heading.Format.Font.Size = 42;
+ heading.Format.Font.Bold = true;
+ heading.Format.SpaceAfter = Unit.FromCentimeter(1.5);
+
+ var to = section.AddParagraph();
+ to.Format.Font.Size = 16;
+ to.Format.SpaceAfter = Unit.FromCentimeter(2);
+ to.AddText("Till mottagning:");
+ to.AddLineBreak();
+ var name = to.AddFormattedText(info.MottagningNamn, TextFormat.Bold);
+ name.Font.Size = 22;
+ to.AddLineBreak();
+ to.AddText($"HSA-id: {info.HsaId}");
+
+ var note = section.AddParagraph("Remiss bifogad som sida 2.");
+ note.Format.Font.Size = 20;
+ note.Format.Font.Bold = true;
+
+ var foot = section.AddParagraph($"Försättsblad genererat {info.Skapad:yyyy-MM-dd HH:mm}");
+ foot.Format.Font.Size = 9;
+ foot.Format.SpaceBefore = Unit.FromCentimeter(3);
+
+ var renderer = new PdfDocumentRenderer { Document = doc };
+ renderer.RenderDocument();
+ renderer.PdfDocument.Save(outputPath);
+ }
+}
diff --git a/src/Remiss.Core/Pdf/PdfMerger.cs b/src/Remiss.Core/Pdf/PdfMerger.cs
new file mode 100644
index 0000000..2841fb9
--- /dev/null
+++ b/src/Remiss.Core/Pdf/PdfMerger.cs
@@ -0,0 +1,44 @@
+using PdfSharp.Pdf;
+using PdfSharp.Pdf.IO;
+
+namespace Remiss.Core.Pdf;
+
+/// <summary>
+/// Sammanfogar PDF:er sida för sida i angiven ordning. Används för
+/// försättsblad (sida 1) + remiss (sida 2 och framåt).
+/// </summary>
+public static class PdfMerger
+{
+ public static void Merge(string outputPath, params string[] inputPaths)
+ {
+ if (inputPaths.Length == 0)
+ throw new ArgumentException("Inga indata-PDF:er angivna.", nameof(inputPaths));
+
+ using var output = new PdfDocument();
+
+ foreach (var path in inputPaths)
+ {
+ if (!File.Exists(path))
+ throw new FileNotFoundException($"PDF att sammanfoga saknas: {path}", path);
+
+ PdfDocument input;
+ try
+ {
+ input = PdfReader.Open(path, PdfDocumentOpenMode.Import);
+ }
+ catch (PdfReaderException ex)
+ {
+ throw new InvalidOperationException(
+ $"Kunde inte läsa PDF:en (skadad eller lösenordsskyddad): {Path.GetFileName(path)}", ex);
+ }
+
+ using (input)
+ {
+ for (var i = 0; i < input.PageCount; i++)
+ output.AddPage(input.Pages[i]);
+ }
+ }
+
+ output.Save(outputPath);
+ }
+}
diff --git a/src/Remiss.Core/Printing/SumatraPdfPrinter.cs b/src/Remiss.Core/Printing/SumatraPdfPrinter.cs
new file mode 100644
index 0000000..1bc4ff0
--- /dev/null
+++ b/src/Remiss.Core/Printing/SumatraPdfPrinter.cs
@@ -0,0 +1,83 @@
+using System.Diagnostics;
+using Remiss.Core.Abstractions;
+
+namespace Remiss.Core.Printing;
+
+/// <summary>
+/// Tyst utskrift via buntad SumatraPDF. Retry-logik eftersom spooler/skrivare
+/// tidvis är otillgänglig.
+/// </summary>
+public sealed class SumatraPdfPrinter : IPrinter
+{
+ private readonly string _exePath;
+ private readonly int _retries;
+ private readonly int _retryDelayMs;
+
+ public SumatraPdfPrinter(string exePath, int retries = 3, int retryDelayMs = 2000)
+ {
+ _exePath = exePath;
+ _retries = Math.Max(1, retries);
+ _retryDelayMs = Math.Max(0, retryDelayMs);
+ }
+
+ public void Print(string pdfPath, string printerName)
+ {
+ if (!File.Exists(_exePath))
+ throw new FileNotFoundException(
+ $"SumatraPDF hittades inte: {_exePath}. Lägg binären i tools/ (se tools/README.md).", _exePath);
+
+ if (string.IsNullOrWhiteSpace(printerName))
+ throw new InvalidOperationException(
+ "Ingen målskrivare konfigurerad (targetPrinter i appsettings.json).");
+
+ Exception? last = null;
+ for (var attempt = 1; attempt <= _retries; attempt++)
+ {
+ try
+ {
+ RunOnce(pdfPath, printerName);
+ return;
+ }
+ catch (Exception ex)
+ {
+ last = ex;
+ if (attempt < _retries)
+ Thread.Sleep(_retryDelayMs);
+ }
+ }
+
+ throw new InvalidOperationException($"Utskrift misslyckades efter {_retries} försök.", last);
+ }
+
+ private void RunOnce(string pdfPath, string printerName)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = _exePath,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ };
+ psi.ArgumentList.Add("-print-to");
+ psi.ArgumentList.Add(printerName);
+ psi.ArgumentList.Add("-silent");
+ psi.ArgumentList.Add(pdfPath);
+
+ using var proc = Process.Start(psi)
+ ?? throw new InvalidOperationException("Kunde inte starta SumatraPDF.");
+
+ if (!proc.WaitForExit(60_000))
+ {
+ try { proc.Kill(entireProcessTree: true); } catch { /* ignore */ }
+ throw new TimeoutException("SumatraPDF svarade inte inom 60 sekunder.");
+ }
+
+ if (proc.ExitCode != 0)
+ {
+ var err = proc.StandardError.ReadToEnd().Trim();
+ throw new InvalidOperationException(
+ $"SumatraPDF avslutades med kod {proc.ExitCode}." + (err.Length > 0 ? $" {err}" : ""));
+ }
+ }
+}
diff --git a/src/Remiss.Core/Remiss.Core.csproj b/src/Remiss.Core/Remiss.Core.csproj
new file mode 100644
index 0000000..35351d5
--- /dev/null
+++ b/src/Remiss.Core/Remiss.Core.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net8.0-windows</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <LangVersion>latest</LangVersion>
+ <RootNamespace>Remiss.Core</RootNamespace>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <!-- MIT-licens, gratis. PdfSharp för merge, MigraDoc för försättsbladet. -->
+ <PackageReference Include="PDFsharp" Version="6.1.1" />
+ <PackageReference Include="PDFsharp-MigraDoc" Version="6.1.1" />
+ <!-- ACL-härdning av temp-mappen. Kan tas bort om SDK:t flaggar NU1510
+ (då ligger typerna redan i net8.0-windows-ramverket). -->
+ <PackageReference Include="System.IO.FileSystem.AccessControl" Version="5.0.0" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/Remiss.Core/RemissWorkflow.cs b/src/Remiss.Core/RemissWorkflow.cs
new file mode 100644
index 0000000..fb3566b
--- /dev/null
+++ b/src/Remiss.Core/RemissWorkflow.cs
@@ -0,0 +1,57 @@
+using Remiss.Core.Abstractions;
+using Remiss.Core.Audit;
+using Remiss.Core.Models;
+using Remiss.Core.Pdf;
+using Remiss.Core.Security;
+
+namespace Remiss.Core;
+
+/// <summary>
+/// Steg [3]–[6] i arkitekturen: generera försättsblad, merge, tyst utskrift,
+/// audit. Städning av temp-filer ägs av anroparen via <see cref="SecureTempStore"/>.
+/// </summary>
+public sealed class RemissWorkflow
+{
+ private readonly AppConfig _config;
+ private readonly IPrinter _printer;
+ private readonly AuditLogger _audit;
+
+ public RemissWorkflow(AppConfig config, IPrinter printer, AuditLogger audit)
+ {
+ _config = config;
+ _printer = printer;
+ _audit = audit;
+ }
+
+ public void Run(string sourcePdfPath, Mottagning mottagning, SecureTempStore temp)
+ {
+ var coverPath = temp.NewFile("cover.pdf");
+ var mergedPath = temp.NewFile("merged.pdf");
+
+ var stage = "init";
+ try
+ {
+ stage = "cover";
+ CoverPageGenerator.Generate(coverPath, new CoverInfo(mottagning.Namn, mottagning.HsaId, DateTime.Now));
+
+ stage = "merge";
+ PdfMerger.Merge(mergedPath, coverPath, sourcePdfPath);
+
+ stage = "print";
+ _printer.Print(mergedPath, _config.TargetPrinter);
+
+ stage = "source-cleanup";
+ if (_config.DeleteSourceAfterPrint)
+ SecureFile.Shred(sourcePdfPath);
+
+ _audit.Write("remiss.sent", mottagning.Namn, mottagning.HsaId, success: true, stage: "done");
+ }
+ catch (Exception ex)
+ {
+ _audit.Write("remiss.failed", mottagning.Namn, mottagning.HsaId,
+ success: false, stage: stage, detail: ex.GetType().Name);
+ _audit.Error($"workflow/{stage}", ex);
+ throw;
+ }
+ }
+}
diff --git a/src/Remiss.Core/Repositories/JsonMottagningRepository.cs b/src/Remiss.Core/Repositories/JsonMottagningRepository.cs
new file mode 100644
index 0000000..30aa180
--- /dev/null
+++ b/src/Remiss.Core/Repositories/JsonMottagningRepository.cs
@@ -0,0 +1,34 @@
+using System.Text.Json;
+using Remiss.Core.Abstractions;
+using Remiss.Core.Models;
+
+namespace Remiss.Core.Repositories;
+
+/// <summary>
+/// POC-implementation som läser <c>mottagningar.json</c> en gång vid start.
+/// </summary>
+public sealed class JsonMottagningRepository : IMottagningRepository
+{
+ private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true };
+
+ private readonly IReadOnlyList<Mottagning> _items;
+
+ public JsonMottagningRepository(string path)
+ {
+ if (!File.Exists(path))
+ throw new FileNotFoundException($"Mottagningsregistret saknas: {path}", path);
+
+ var raw = JsonSerializer.Deserialize<List<Mottagning>>(File.ReadAllText(path), Options)
+ ?? new List<Mottagning>();
+
+ _items = raw
+ .Where(m => !string.IsNullOrWhiteSpace(m.Namn) && !string.IsNullOrWhiteSpace(m.HsaId))
+ .OrderBy(m => m.Namn, StringComparer.CurrentCulture)
+ .ToList();
+ }
+
+ public IReadOnlyList<Mottagning> GetAll() => _items;
+
+ public Mottagning? FindByHsaId(string hsaId) =>
+ _items.FirstOrDefault(m => string.Equals(m.HsaId, hsaId, StringComparison.OrdinalIgnoreCase));
+}
diff --git a/src/Remiss.Core/Security/SecureFile.cs b/src/Remiss.Core/Security/SecureFile.cs
new file mode 100644
index 0000000..8e1a5f9
--- /dev/null
+++ b/src/Remiss.Core/Security/SecureFile.cs
@@ -0,0 +1,39 @@
+namespace Remiss.Core.Security;
+
+/// <summary>
+/// Bäst-möjliga radering av filer som innehållit patientdata: skriv över
+/// innehållet en gång och radera. Inte forensiskt vattentätt på SSD/kopiering,
+/// men höjer ribban och håller filerna ur "återställ borttaget"-listor.
+/// </summary>
+public static class SecureFile
+{
+ public static void Shred(string path)
+ {
+ try
+ {
+ if (!File.Exists(path))
+ return;
+
+ var length = new FileInfo(path).Length;
+ using (var fs = new FileStream(path, FileMode.Open, FileAccess.Write, FileShare.None))
+ {
+ var buffer = new byte[81920];
+ long written = 0;
+ while (written < length)
+ {
+ var chunk = (int)Math.Min(buffer.Length, length - written);
+ fs.Write(buffer, 0, chunk);
+ written += chunk;
+ }
+ fs.Flush(flushToDisk: true);
+ }
+
+ File.Delete(path);
+ }
+ catch
+ {
+ // Sista utväg: se till att filen åtminstone försvinner.
+ try { File.Delete(path); } catch { /* ignore */ }
+ }
+ }
+}
diff --git a/src/Remiss.Core/Security/SecureTempStore.cs b/src/Remiss.Core/Security/SecureTempStore.cs
new file mode 100644
index 0000000..e8fb9d4
--- /dev/null
+++ b/src/Remiss.Core/Security/SecureTempStore.cs
@@ -0,0 +1,75 @@
+using System.Security.AccessControl;
+using System.Security.Principal;
+
+namespace Remiss.Core.Security;
+
+/// <summary>
+/// En jobb-isolerad temp-mapp för PDF:er som innehåller patientdata.
+/// <list type="bullet">
+/// <item>Ligger under LOCALAPPDATA (användarprofil), inte %TEMP%.</item>
+/// <item>ACL låses till nuvarande användare + SYSTEM + Administrators.</item>
+/// <item><see cref="Dispose"/> skriver över och raderar allt – körs även vid fel.</item>
+/// </list>
+/// </summary>
+public sealed class SecureTempStore : IDisposable
+{
+ public string Root { get; }
+
+ public SecureTempStore(string baseDir)
+ {
+ Directory.CreateDirectory(baseDir);
+ Root = Path.Combine(baseDir, "job-" + Guid.NewGuid().ToString("N"));
+ var dir = Directory.CreateDirectory(Root);
+ TryRestrictAccess(dir);
+ }
+
+ public string NewFile(string name) => Path.Combine(Root, name);
+
+ [System.Runtime.Versioning.SupportedOSPlatform("windows")]
+ private static void TryRestrictAccess(DirectoryInfo dir)
+ {
+ try
+ {
+ var security = new DirectorySecurity();
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+
+ const InheritanceFlags inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
+
+ var me = WindowsIdentity.GetCurrent().User!;
+ security.AddAccessRule(new FileSystemAccessRule(
+ me, FileSystemRights.FullControl, inherit, PropagationFlags.None, AccessControlType.Allow));
+
+ var admins = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
+ security.AddAccessRule(new FileSystemAccessRule(
+ admins, FileSystemRights.FullControl, inherit, PropagationFlags.None, AccessControlType.Allow));
+
+ var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
+ security.AddAccessRule(new FileSystemAccessRule(
+ system, FileSystemRights.FullControl, inherit, PropagationFlags.None, AccessControlType.Allow));
+
+ dir.SetAccessControl(security);
+ }
+ catch
+ {
+ // Bäst-möjligt: profilmappen är redan användar-scopad även utan detta.
+ }
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (!Directory.Exists(Root))
+ return;
+
+ foreach (var file in Directory.EnumerateFiles(Root, "*", SearchOption.AllDirectories))
+ SecureFile.Shred(file);
+
+ Directory.Delete(Root, recursive: true);
+ }
+ catch
+ {
+ // ignore – nästa körning städar kvarvarande job-* mappar vid behov
+ }
+ }
+}
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 0000000..eaa128a
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,26 @@
+# tools/
+
+Här ska den buntade tredjepartsbinären för tyst utskrift ligga. Den checkas **inte** in
+(se `.gitignore`) – varje utvecklare/byggpipeline hämtar den själv.
+
+## SumatraPDF (default)
+
+1. Hämta portable-versionen, 64-bit, från <https://www.sumatrapdfreader.org/download-free-pdf-viewer>
+2. Lägg filen här och döp den till exakt `SumatraPDF.exe`
+3. Sökvägen styrs av `sumatraPdfPath` i `config/appsettings.json`
+
+Utskriftsmodulen anropar den som:
+
+```
+SumatraPDF.exe -print-to "<skrivarnamn>" -silent <pdf>
+```
+
+### Licens
+
+SumatraPDF är GPLv3. Vi länkar inte mot den – vi startar den som en fristående
+process via kommandoraden (aggregation, inte avledd verk). Det är i praktiken
+oproblematiskt, men stäm av med VGR:s juridik innan bred utrullning i Fas 4.
+
+## Alternativ: gsprint / Ghostscript
+
+Om SumatraPDF inte duger, byt `IPrinter`-implementationen i `Remiss.Core/Printing`.