diff --git a/CHANGELOG.md b/CHANGELOG.md index 78da0f4..7a80368 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,53 @@ Wiederherstellung*. _Noch nichts._ +## [1.5.0] – 2026-08-23 + +### Hinzugefügt + +- **Schreiben als E-Mail übernehmen** + ([#5](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/5)). + Vieles geht heute per E-Mail hinaus, nicht per Brief. Im Schreiben-Editor + gibt es jetzt den Umschalter **Als Brief / Als E-Mail**. + + Die E-Mail-Fassung wird aus dem — womöglich schon bearbeiteten — + Brieftext abgeleitet: ohne Absenderblock, Anschrift und Datumszeile, die + gehören in den Brief und nicht in die Mail. **Der Bestellungsabsatz + bleibt drin** — ohne ihn kommt die Auskunft nicht. Betreff und Text sind + einzeln kopierbar. + +- **Zwei Wege ins Mailprogramm.** Die offene Entscheidung aus dem Issue + (mailto oder .eml) ist mit *beides* beantwortet: + + **„Im Mailprogramm öffnen"** (mailto) für kurze Texte — der Knopf + verschwindet, sobald der kodierte Link 1800 Zeichen übersteigt. mailto + übergibt den Text in der Adresszeile, deren Länge nirgends zugesichert + ist; ein stillschweigend halbierter Brief fällt erst beim Empfänger auf. + + **„Als E-Mail-Entwurf (.eml)"** immer: eine Datei, die sich im + Mailprogramm als fertiger Entwurf öffnet — Adresse, Betreff und voller + Text garantiert vollständig, mit korrekt kodierten Umlauten. Die Datei + erfindet bewusst keinen Absender: den trägt das Mailprogramm beim + Senden selbst ein. + + Beide Knöpfe erscheinen nur, wenn eine E-Mail-Adresse da ist — aus dem + gewählten Kontakt übernommen oder von Hand eingetragen. Ohne Adresse + bleibt das Kopieren. + +- **Die Ablage kennt den Unterschied.** Eine als E-Mail abgelegte + Nachricht heißt in der Akte „E-Mail: …" und die PDF-Fassung trägt die + Empfängeradresse im Kopf — abgelegt wird die E-Mail-Fassung, nicht der + Brieftext. In der Akte soll stehen, was tatsächlich hinausging. + +### Geprüft + +- 20 Einheitstests: die Ableitung der E-Mail-Fassung (nichts geht + stillschweigend verloren, der Bestellungsabsatz bleibt), die + mailto-Längengrenze **nach** der Kodierung, das .eml-Format samt + Umlauten nach RFC 2047 und quoted-printable. +- Vier End-to-End-Tests: Fassung ohne Briefkopf, .eml-Download, kein + Mailknopf ohne Adresse, Ablage als gekennzeichnete E-Mail. + ## [1.4.0] – 2026-08-23 ### Hinzugefügt @@ -768,7 +815,8 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md). --- -[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...HEAD +[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...HEAD +[1.5.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...v1.5.0 [1.4.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.3.0...v1.4.0 [1.3.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.2.0...v1.3.0 [1.2.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.1.2...v1.2.0 diff --git a/README.md b/README.md index c1db96f..8e30974 100644 --- a/README.md +++ b/README.md @@ -580,11 +580,11 @@ Fehler, die während des Baus aufgefallen sind. | | | nachzuzählen mit | |---|---:|---| -| Zeilen TypeScript/TSX | 76.610 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | +| Zeilen TypeScript/TSX | 78.486 | `git ls-files "*.ts" "*.tsx" \| xargs wc -l` | | Prisma-Modelle | 34 | `grep -c '^model ' prisma/schema.prisma` | | Seiten | 81 | `find src/app -name page.tsx \| wc -l` | -| Unit-Tests (Vitest) | 570 | `npm test` | -| End-to-End-Tests (Playwright) | 73 | `npm run test:e2e` — 40 Tests über zwei Geräteprofile, geräteabhängige übersprungen | +| Unit-Tests (Vitest) | 590 | `npm test` | +| End-to-End-Tests (Playwright) | 81 | `npm run test:e2e` — 44 Tests über zwei Geräteprofile, geräteabhängige übersprungen | | TypeScript-Fehler | 0 | `npm run typecheck` | Die Befehle stehen dabei, weil die Zahlen sonst altern, ohne dass es diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index fe95c00..adaddd5 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -131,14 +131,14 @@ cd /opt/betreuungs-cockpit # Auf die letzte veröffentlichte Fassung wechseln, # nicht auf den laufenden Entwicklungsstand -git checkout v1.4.0 +git checkout v1.5.0 ``` ### Kontrolle ```bash ls docker-compose.yml Dockerfile .env.example # alle drei da -git describe --tags # v1.4.0 +git describe --tags # v1.5.0 ``` --- @@ -299,7 +299,7 @@ betreuungs-cockpit-sicherung-1 Up ```bash # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) curl -s http://127.0.0.1:3000/api/gesundheit -# {"status":"bereit","version":"1.4.0","commit":"..."} +# {"status":"bereit","version":"1.5.0","commit":"..."} ``` Kommt hier nichts, sieh in die Logs: @@ -649,7 +649,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an? ```bash curl -s http://10.0.0.5:3000/api/gesundheit -# {"status":"bereit","version":"1.4.0",...} +# {"status":"bereit","version":"1.5.0",...} ``` Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der @@ -700,7 +700,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Protokoll** siehst du deine eigene Anmeldung. -- Unten links in der Seitenleiste steht `Version 1.4.0 · `. +- Unten links in der Seitenleiste steht `Version 1.5.0 · `. --- @@ -875,7 +875,7 @@ alles so vor wie zuvor. | Aufruf | Wirkung | |---|---| | `./scripts/update.sh` | auf die neueste veröffentlichte Fassung | -| `./scripts/update.sh v1.4.0` | auf eine bestimmte Fassung | +| `./scripts/update.sh v1.5.0` | auf eine bestimmte Fassung | | `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern | | `./scripts/update.sh --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | @@ -913,7 +913,7 @@ git fetch --tags git log --oneline HEAD..origin/main # 3. Umschalten -git checkout v1.4.0 +git checkout v1.5.0 # 4. Neu bauen und starten GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 594d040..5ae3df0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre. Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb als wichtiger herausstellt, rutscht nach oben. -Stand: 23.08.2026 · Aktuelle Fassung: **1.4.0** ([CHANGELOG](../CHANGELOG.md)) +Stand: 23.08.2026 · Aktuelle Fassung: **1.5.0** ([CHANGELOG](../CHANGELOG.md)) --- diff --git a/e2e/email.spec.ts b/e2e/email.spec.ts new file mode 100644 index 0000000..9131550 --- /dev/null +++ b/e2e/email.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test' +import { PrismaClient } from '@prisma/client' +import { anmelden } from './hilfen' + +/** + * Schreiben als E-Mail — gegen die echte Anwendung. + * + * Geprüft wird der Weg, den die Einheitstests nicht sehen: Umschalten auf + * E-Mail, die abgeleitete Fassung ohne Briefkopf, der .eml-Download und + * die Ablage, die das Schreiben als E-Mail kennzeichnet. + */ + +const KONTAKT = 'Prüfhausen Versicherung (E-Mail-Test)' + +async function mitDb(arbeit: (db: PrismaClient) => Promise): Promise { + const db = new PrismaClient() + try { + return await arbeit(db) + } finally { + await db.$disconnect() + } +} + +async function aufraeumen(): Promise { + await mitDb(async (db) => { + await db.dokument.deleteMany({ where: { empfaenger: { contains: 'Prüfhausen' } } }) + await db.frist.deleteMany({ where: { titel: { contains: 'Antwort abwarten' } } }) + await db.kontakt.deleteMany({ where: { name: { contains: 'Prüfhausen' } } }) + }) +} + +test.describe('Schreiben als E-Mail', () => { + test.beforeAll(async () => { + await aufraeumen() + await mitDb(async (db) => { + const betreute = await db.betreute.findMany({ + where: { geloeschtAm: null }, + select: { id: true }, + }) + for (const person of betreute) { + await db.kontakt.create({ + data: { + betreuteId: person.id, + name: KONTAKT, + organisation: KONTAKT, + rolle: 'SONSTIGES', + email: 'service@pruefhausen.example', + }, + }) + } + }) + }) + + test.afterAll(async () => { + await aufraeumen() + }) + + test('leitet die E-Mail-Fassung ohne Briefkopf ab, mit Bestellungsabsatz', async ({ + page, + }) => { + await anmelden(page) + await page.goto('/schreiben/neu?art=FREI') + + // Kontakt wählen - bringt die E-Mail-Adresse mit. + await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` }) + await expect(page.locator('input#empfaengerEmail')).toHaveValue( + 'service@pruefhausen.example', + ) + + await page.getByRole('button', { name: 'Als E-Mail' }).click() + + const mailtext = await page.locator('textarea#emailText').inputValue() + // Beginnt mit der Anrede - kein Absenderblock, keine Datumszeile. + expect(mailtext.startsWith('Sehr geehrte')).toBe(true) + expect(mailtext).not.toMatch(/^\d{1,2}\.\d{1,2}\.\d{4}$/m) + // Der Nachweis der Bestellung bleibt drin. + expect(mailtext).toMatch(/Betreuungsgericht|bestellt/i) + }) + + test('lädt den .eml-Entwurf herunter', async ({ page }) => { + await anmelden(page) + await page.goto('/schreiben/neu?art=FREI') + await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` }) + await page.getByRole('button', { name: 'Als E-Mail' }).click() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' }).click(), + ]) + + expect(download.suggestedFilename()).toMatch(/^\d{4}-\d{2}-\d{2}_.*\.eml$/) + }) + + test('zeigt ohne Adresse keine Mailprogramm-Knöpfe', async ({ page }) => { + await anmelden(page) + await page.goto('/schreiben/neu?art=FREI') + await page.getByRole('button', { name: 'Als E-Mail' }).click() + + // Kopieren geht immer, die Programm-Knöpfe nicht. + await expect(page.getByRole('button', { name: 'Text kopieren' }).last()).toBeVisible() + await expect(page.getByRole('button', { name: 'Als E-Mail-Entwurf (.eml)' })).toHaveCount(0) + await expect(page.getByRole('link', { name: 'Im Mailprogramm öffnen' })).toHaveCount(0) + await expect(page.getByText(/Trag oben eine E-Mail-Adresse ein/)).toBeVisible() + }) + + test('legt die E-Mail als solche gekennzeichnet in die Akte', async ({ page }) => { + await anmelden(page) + await page.goto('/schreiben/neu?art=FREI') + await page.locator('select#kontaktAuswahl').selectOption({ label: `${KONTAKT}` }) + + await page.locator('input#betreff').fill('Prüfhausen Testanfrage') + await page.getByRole('button', { name: 'Als E-Mail' }).click() + await page.getByRole('button', { name: 'In die Akte legen' }).click() + + await expect(page.getByText('Das Schreiben liegt in der Akte.')).toBeVisible({ + timeout: 30_000, + }) + + // In der Akte als E-Mail erkennbar, nicht als Brief. + await page.goto('/dokumente?richtung=AUSGANG') + await expect(page.locator('body')).toContainText('E-Mail: Prüfhausen Testanfrage') + }) +}) diff --git a/package.json b/package.json index 6397e66..0ce2b1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.4.0", + "version": "1.5.0", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/src/app/(app)/schreiben/eml/route.ts b/src/app/(app)/schreiben/eml/route.ts new file mode 100644 index 0000000..502ed9b --- /dev/null +++ b/src/app/(app)/schreiben/eml/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from 'next/server' +import { erfordereZugang, ZugriffVerweigert } from '@/lib/auth/sitzung' +import { emailFassung, siehtAusWieEmail } from '@/lib/schreiben/email' +import { emlErzeugen } from '@/lib/schreiben/eml' +import { dateinameFuerSchreiben, istSchreibenArt } from '@/lib/schreiben/arten' +import { heuteAlsTag } from '@/lib/fristen/datum' + +/** + * Das Schreiben als .eml-Entwurf herunterladen. + * + * Gleicher Aufbau wie der Word-Download (../docx/route.ts): POST, weil der + * Text mitkommt und nicht in die Adresszeile gehoert; Zugriff wird hier + * geprueft, weil sich eine Route unmittelbar aufrufen laesst. + */ + +export const dynamic = 'force-dynamic' + +export async function POST(anfrage: Request): Promise { + let formular: FormData + try { + formular = await anfrage.formData() + } catch { + return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 }) + } + + const betreuteId = String(formular.get('betreuteId') ?? '') + const rohArt = String(formular.get('art') ?? '') + const text = String(formular.get('text') ?? '') + const betreff = String(formular.get('betreff') ?? '').trim() + const adresse = String(formular.get('empfaengerEmail') ?? '').trim() + const empfaenger = String(formular.get('empfaengerName') ?? '').trim() + + if (!betreuteId || !istSchreibenArt(rohArt) || text.trim().length < 30) { + return NextResponse.json({ fehler: 'Unvollständige Angaben.' }, { status: 400 }) + } + if (!siehtAusWieEmail(adresse)) { + return NextResponse.json( + { fehler: 'Für diesen Empfänger ist keine E-Mail-Adresse hinterlegt.' }, + { status: 400 }, + ) + } + + try { + await erfordereZugang(betreuteId, 'DOKUMENTE', 'lesen') + } catch (ausnahme) { + if (ausnahme instanceof ZugriffVerweigert) { + return NextResponse.json({ fehler: ausnahme.message }, { status: 403 }) + } + throw ausnahme + } + + const fassung = emailFassung(text, betreff) + const datei = emlErzeugen({ an: adresse, betreff: fassung.betreff, text: fassung.text }) + const dateiname = dateinameFuerSchreiben(rohArt, empfaenger || null, heuteAlsTag(), 'eml') + + return new NextResponse(new Uint8Array(datei), { + headers: { + 'Content-Type': 'message/rfc822', + 'Content-Disposition': `attachment; filename="${dateiname}"`, + 'Content-Length': String(datei.length), + 'Cache-Control': 'no-store, must-revalidate', + }, + }) +} diff --git a/src/app/(app)/schreiben/neu/page.tsx b/src/app/(app)/schreiben/neu/page.tsx index dbd2346..5c2e022 100644 --- a/src/app/(app)/schreiben/neu/page.tsx +++ b/src/app/(app)/schreiben/neu/page.tsx @@ -79,7 +79,15 @@ export default async function SchreibenSeite({ const kontakte = await db.kontakt.findMany({ where: { betreuteId: aktive.id, ...nichtGeloescht }, - select: { id: true, name: true, organisation: true, strasse: true, plz: true, ort: true }, + select: { + id: true, + name: true, + organisation: true, + strasse: true, + plz: true, + ort: true, + email: true, + }, orderBy: [{ organisation: 'asc' }, { name: 'asc' }], take: 200, }) diff --git a/src/components/schreiben/schreiben-editor.tsx b/src/components/schreiben/schreiben-editor.tsx index d953282..6eec454 100644 --- a/src/components/schreiben/schreiben-editor.tsx +++ b/src/components/schreiben/schreiben-editor.tsx @@ -10,6 +10,7 @@ import { FileDown, FolderInput, Info, + Mail, UserPlus, } from 'lucide-react' import { Alert, AlertDescription } from '@/components/ui/alert' @@ -26,6 +27,7 @@ import { type SchreibenZustand, } from '@/server/actions/schreiben' import type { SchreibenArt } from '@/lib/schreiben/arten' +import { emailFassung, mailtoLink, siehtAusWieEmail } from '@/lib/schreiben/email' export type KontaktAuswahl = { id: string @@ -34,6 +36,7 @@ export type KontaktAuswahl = { strasse: string | null plz: string | null ort: string | null + email: string | null } /** @@ -67,6 +70,10 @@ export function SchreibenEditor({ const [betreff, setBetreff] = React.useState(entwurf.betreff) const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName) const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('') + const [empfaengerEmail, setEmpfaengerEmail] = React.useState('') + const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF') + const [betreffKopiert, setBetreffKopiert] = React.useState(false) + const [mailKopiert, setMailKopiert] = React.useState(false) const [versandAm, setVersandAm] = React.useState(new Date().toISOString().slice(0, 10)) const [wiedervorlageAm, setWiedervorlageAm] = React.useState(entwurf.wiedervorlageAm) const [kopiert, setKopiert] = React.useState(false) @@ -94,6 +101,8 @@ export function SchreibenEditor({ + + ) } @@ -107,6 +116,7 @@ export function SchreibenEditor({ .filter(Boolean) .join('\n'), ) + setEmpfaengerEmail(kontakt.email ?? '') } async function textKopieren() { @@ -121,6 +131,109 @@ export function SchreibenEditor({ const abgelegt = Boolean(ablageZustand.erfolg) + /** + * Die E-Mail-Fassung: Betreff und Rumpf getrennt, ohne Briefkopf. + * + * Abgeleitet aus dem AKTUELLEN Text im Feld - wer den Brief bearbeitet + * hat, bekommt die bearbeitete Fassung, nicht den Ursprungsentwurf. + */ + function EmailAusgabe() { + const fassung = emailFassung(text, betreff) + const adresseGueltig = siehtAusWieEmail(empfaengerEmail) + const mailto = adresseGueltig ? mailtoLink(empfaengerEmail, fassung) : null + + async function kopieren(inhalt: string, merker: (wert: boolean) => void) { + try { + await navigator.clipboard.writeText(inhalt) + merker(true) + window.setTimeout(() => merker(false), 2500) + } catch { + /* Zwischenablage gesperrt - dann markiert man eben von Hand. */ + } + } + + return ( +
+ +
+ + +
+
+ + +
+