diff --git a/UAT-Featureliste Mieterportal.xlsx b/UAT-Featureliste Mieterportal.xlsx new file mode 100644 index 0000000..c3a7849 Binary files /dev/null and b/UAT-Featureliste Mieterportal.xlsx differ diff --git a/UAT-Featureliste Mieterportal_recalc_verify.xlsx b/UAT-Featureliste Mieterportal_recalc_verify.xlsx new file mode 100644 index 0000000..55d81d0 Binary files /dev/null and b/UAT-Featureliste Mieterportal_recalc_verify.xlsx differ diff --git a/kimi.md b/kimi.md index 0196b17..36d56a7 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,27 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.22):** Direkt-E-Mail der Verwaltung an Mieter mit +automatischem CC an die Verwaltung. Mail-API (mail-server/api/server.py, +auf dem Server manuell ausgerollt, Container mp-mail-api per docker restart – +NICHT Teil von deploy.sh) um cc-Feld erweitert: Body cc kommagetrennt, +einzeln per EMAIL_RE validiert, versenden setzt msg["Cc"]. ACHTUNG Repo- +Stand vorher von Server frisch gezogen (from_address/from_name war nur +serverseitig). API: POST /api/users/:id/email (adminOnly) – Betreff/Text +Pflicht, Mieter muss E-Mail hinterlegt haben (sonst 400 mit Hinweis), +HTML-Karte baueHtmlMail, CC = mail.verwaltungEmails() (alle aktiven +Verwaltungs-Accounts mit E-Mail), Versand fire-and-forget via sendeMail(…, cc). +PATCH /api/users/:id akzeptiert jetzt auch email (Format- + Eindeutigkeits- +prüfung, LEERE E-Mail darf mehrfach vorkommen – Eindeutigkeit nur bei +nicht-leer, sonst Fehler beim Leeren). Frontend Benutzerverwaltung: +EmailDialog pro Mieter (Empfängeranzeige, Betreff, Nachricht, Hinweis auf +CC, Senden deaktiviert ohne Mieter-E-Mail), E-Mail-Adresse jetzt inline im +Benutzer-Eintrag editierbar (onBlur → setzeBenutzerEmail). Store: +sendeBenutzerEmail + setzeBenutzerEmail. E2E verifiziert: E-Mail setzen, +senden → Postfix eine queue-ID mit 3 Empfängern (Testadresse bounced +erwartet, joseph@hinterstein-immo.de + axel@hocks.eu status=sent), +Fehlerfall ohne E-Mail sauber, Testdaten bereinigt. + **Erledigt 04.10. (v1.21):** Live-Stammdaten aus SharePoint-Export-CSV importiert (KEIN Dauersync – Nutzer-Entscheidung: einmaliger Import, Pflege danach nur noch im Portal). CSV „Master Data V" (53 Zeilen, diff --git a/mail-server/api/__pycache__/server.cpython-312.pyc b/mail-server/api/__pycache__/server.cpython-312.pyc index 8ae53f9..438ea47 100644 Binary files a/mail-server/api/__pycache__/server.cpython-312.pyc and b/mail-server/api/__pycache__/server.cpython-312.pyc differ diff --git a/mail-server/api/server.py b/mail-server/api/server.py index c78fdea..d4e57be 100644 --- a/mail-server/api/server.py +++ b/mail-server/api/server.py @@ -12,7 +12,8 @@ Endpunkte: GET /healthz → {"ok": true} GET /outbox → aktuelle Outbox (Demo/Debug) POST /send → {"ok": true, "id": ..., "versandt": true/false} - Body: {"to": "...", "subject": "...", "text": "...", "html": "..."} + Body: {"to": "...", "subject": "...", "text": "...", "html": "...", + optional "cc": "a@b.de, c@d.de", "from_address"/"from_name" (nur @hocks.eu)} Auth: Header X-Api-Token oder Body-Feld "token" == MAIL_API_TOKEN """ @@ -78,8 +79,10 @@ def rate_limit_ok() -> bool: def versenden(e: dict) -> bool: msg = EmailMessage() - msg["From"] = f"{SENDER_NAME} <{SENDER_ADDRESS}>" + msg["From"] = f"{e.get('from_name') or SENDER_NAME} <{e.get('from_address') or SENDER_ADDRESS}>" msg["To"] = e["to"] + if e.get("cc"): + msg["Cc"] = e["cc"] # Komma-getrennte CC-Empfaenger (v1.22) msg["Subject"] = e["subject"] msg.set_content(e.get("text") or "") if e.get("html"): @@ -172,12 +175,26 @@ class Handler(BaseHTTPRequestHandler): if not rate_limit_ok(): self._antwort(429, {"fehler": "Rate-Limit erreicht"}) return + # Optionaler eigener Absender (nur hocks.eu-Domain, sonst Fallback) + absender = str(body.get("from_address", "")).strip().lower() + absender_name = str(body.get("from_name", "")).strip() + if (not EMAIL_RE.match(absender) or not absender.endswith("@hocks.eu") + or len(absender_name) > 60 or any(c in absender_name for c in "<>\r\n")): + absender, absender_name = SENDER_ADDRESS, SENDER_NAME + # Optional CC (Komma-getrennt, einzeln validiert) – v1.22 + cc = ','.join( + a for a in (x.strip() for x in str(body.get("cc", "")).split(",")) + if a and EMAIL_RE.match(a) + ) eintrag = { "id": uuid.uuid4().hex[:12], "to": an, "subject": betreff, "text": str(body.get("text", "")), "html": str(body.get("html", "")), + "cc": cc, + "from_address": absender, + "from_name": absender_name, "versuche": 0, "versandt": False, "angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"), diff --git a/make-uat-liste.py b/make-uat-liste.py new file mode 100644 index 0000000..c4a28e2 --- /dev/null +++ b/make-uat-liste.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""UAT-Featureliste für das Hinterstein-Mieterportal (Arbeitsvorlage).""" +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.datavalidation import DataValidation + +AKZENT = '0F766E' # Hinterstein-Türkis +AKZENT_HELL = 'CCFBF1' +GRAU = 'F1F5F9' +DUNKEL = '22333B' +ROT = 'FEE2E2' +GELB = 'FEF9C3' +GRUEN = 'DCFCE7' + +wb = Workbook() + +# ── Blatt 1: Featureliste ───────────────────────────────────────────── +ws = wb.active +ws.title = 'Featureliste' +ws.sheet_view.showGridLines = False + +ws.merge_cells('B2:K2') +c = ws['B2'] +c.value = 'UAT & Feature-Wunschliste – Hinterstein Mieterportal' +c.font = Font(bold=True, size=16, color=DUNKEL) +ws.merge_cells('B3:K3') +c = ws['B3'] +c.value = ('Gewünschte Features erfassen: Zeile ergänzen, Modul wählen, Priorität setzen. ' + 'Die Entwicklung aktualisiert den Status. Beispielzeilen 001–003 gern überschreiben.') +c.font = Font(size=10, color='64748B') + +# Status-Kennzahlen (Formeln) +kennz = [('Offen', 'Offen'), ('In Prüfung', 'In Prüfung'), ('Umgesetzt', 'Umgesetzt'), ('Abgelehnt', 'Abgelehnt')] +for i, (label, wert) in enumerate(kennz): + col = 2 + i * 2 + lc = ws.cell(row=5, column=col, value=label) + lc.font = Font(size=9, bold=True, color='64748B') + vc = ws.cell(row=6, column=col, value=f'=COUNTIF($I$10:$I$109,"{wert}")') + vc.font = Font(size=14, bold=True, color=AKZENT) + vc.alignment = Alignment(horizontal='left') + +KOPF = ['ID', 'Modul', 'Feature / Anforderung', 'Beschreibung / Akzeptanzkriterium', + 'Priorität', 'Wunschtermin', 'Eingereicht von', 'Eingereicht am', 'Status', + 'Ergebnis im Test / Bemerkung'] +BREITEN = [7, 20, 30, 48, 11, 13, 15, 12, 12, 34] +SPALTEN = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'] +HEADER_ZEILE = 9 + +for j, (name, breite, buchstabe) in enumerate(zip(KOPF, BREITEN, SPALTEN)): + cell = ws.cell(row=HEADER_ZEILE, column=2 + j, value=name) + cell.font = Font(bold=True, color='FFFFFF', size=10) + cell.fill = PatternFill('solid', fgColor=DUNKEL) + cell.alignment = Alignment(vertical='center', wrap_text=True) + ws.column_dimensions[buchstabe].width = breite +ws.row_dimensions[HEADER_ZEILE].height = 28 + +MODULE = ['Dashboard', 'Mietkonto & Zahlungen', 'Schadensmeldungen', 'Zählerstände', + 'Steuer & Kosten', 'Dokumente', 'Nachrichten', 'Benutzerverwaltung', + 'Mein Profil', 'Häuser & Wohnungen', 'Mietverträge', 'Technik allgemein'] +PRIOS = ['Hoch', 'Mittel', 'Niedrig'] +STATUS = ['Offen', 'In Prüfung', 'Umgesetzt', 'Abgelehnt'] + +beispiele = [ + ['001', 'Steuer & Kosten', 'Nebenkostenabrechnung als PDF', + 'Pro Wohnung und Abrechnungsjahr eine PDF-Abrechnung aus Kosten + Zählerständen erzeugen und im Dokumentenmodul ablegen.', + 'Mittel', '', '', '', 'Offen', ''], + ['002', 'Benutzerverwaltung', 'E-Mail-Adressen importierter Mieter erfassen', + 'Fehlende E-Mail-Adressen der importierten Mieter erfassen, damit Benachrichtigungen versendet werden können.', + 'Hoch', '', '', '', 'Offen', ''], + ['003', 'Mietkonto & Zahlungen', 'Zahlungseingang manuell als erledigt markieren', + 'Verwaltung kann eine offene Buchung als „bezahlt“ markieren; Saldo und Fälligkeiten aktualisieren sich.', + 'Niedrig', '', '', '', 'Offen', ''], +] + +duenn = Side(style='thin', color='E2E8F0') +for i in range(100): + zeile = 10 + i + daten = beispiele[i] if i < len(beispiele) else [f'{i+1:03d}', '', '', '', '', '', '', '', 'Offen', ''] + for j, wert in enumerate(daten): + cell = ws.cell(row=zeile, column=2 + j, value=wert) + cell.font = Font(size=10) + cell.alignment = Alignment(vertical='top', wrap_text=(j in (2, 3, 9))) + cell.border = Border(bottom=duenn) + if i % 2 == 1: + cell.fill = PatternFill('solid', fgColor=GRAU) + +dv_modul = DataValidation(type='list', formula1='"' + ','.join(MODULE) + '"', allow_blank=True) +dv_prio = DataValidation(type='list', formula1='"' + ','.join(PRIOS) + '"', allow_blank=True) +dv_status = DataValidation(type='list', formula1='"' + ','.join(STATUS) + '"', allow_blank=True) +ws.add_data_validation(dv_modul) +ws.add_data_validation(dv_prio) +ws.add_data_validation(dv_status) +dv_modul.add('C10:C109') +dv_prio.add('F10:F109') +dv_status.add('J10:J109') + +# Bedingte Formatierung: Priorität + Status +from openpyxl.formatting.rule import CellIsRule +ws.conditional_formatting.add('F10:F109', + CellIsRule(operator='equal', formula=['"Hoch"'], + fill=PatternFill('solid', fgColor=ROT), font=Font(bold=True, size=10))) +ws.conditional_formatting.add('F10:F109', + CellIsRule(operator='equal', formula=['"Mittel"'], + fill=PatternFill('solid', fgColor=GELB), font=Font(size=10))) +ws.conditional_formatting.add('F10:F109', + CellIsRule(operator='equal', formula=['"Niedrig"'], + fill=PatternFill('solid', fgColor=GRUEN), font=Font(size=10))) +ws.conditional_formatting.add('J10:J109', + CellIsRule(operator='equal', formula=['"Umgesetzt"'], + fill=PatternFill('solid', fgColor=GRUEN), font=Font(size=10))) +ws.conditional_formatting.add('J10:J109', + CellIsRule(operator='equal', formula=['"Abgelehnt"'], + fill=PatternFill('solid', fgColor='E2E8F0'), font=Font(size=10, color='64748B'))) + +ws.freeze_panes = 'B10' +ws.sheet_view.zoomScale = 90 + +# ── Blatt 2: Anleitung ──────────────────────────────────────────────── +wa = wb.create_sheet('Anleitung') +wa.sheet_view.showGridLines = False +wa.column_dimensions['B'].width = 28 +wa.column_dimensions['C'].width = 95 + +wa.merge_cells('B2:C2') +c = wa['B2'] +c.value = 'So verwenden Sie diese Liste' +c.font = Font(bold=True, size=14, color=DUNKEL) + +bloecke = [ + ('Neuen Wunsch erfassen', + 'In der Tabelle „Featureliste“ eine leere Zeile nutzen (oder eine Beispielzeile überschreiben): ' + 'Modul aus dem Dropdown wählen, Feature kurz benennen und im Feld „Beschreibung / Akzeptanzkriterium“ ' + 'notieren, wann das Feature als fertig gilt. Je konkreter das Akzeptanzkriterium, desto schneller die Umsetzung.'), + ('Priorität', + 'Hoch = dringend für den Betrieb · Mittel = wichtig, aber zeitunkritisch · Niedrig = Nice-to-have.'), + ('Status (wird von der Entwicklung gepflegt)', + 'Offen → In Prüfung (Aufwand wird bewertet) → Umgesetzt (nach Auslieferung) bzw. Abgelehnt (mit Begründung in der letzten Spalte).'), + ('UAT – Abnahme testen', + 'Nach Umsetzung im Portal gegen das Akzeptanzkriterium testen. Ergebnis in der Spalte ' + '„Ergebnis im Test / Bemerkung“ festhalten: „OK“ oder Fehlerbeschreibung mit Schritten zum Nachvollziehen. ' + 'Gefundene Fehler bitte als neue Zeile mit Priorität „Hoch“ eintragen.'), + ('Übertrag an die Entwicklung', + 'Liste speichern und an die Entwicklung senden (E-Mail oder Kopie ins Projektverzeichnis). ' + 'Die Entwicklung legt die übernommenen Punkte als Versions-Einträge an und verweist auf die IDs.'), +] +zeile = 4 +for titel, text in bloecke: + tc = wa.cell(row=zeile, column=2, value=titel) + tc.font = Font(bold=True, size=11, color=AKZENT) + tc.alignment = Alignment(vertical='top') + cc = wa.cell(row=zeile, column=3, value=text) + cc.font = Font(size=10, color='334155') + cc.alignment = Alignment(vertical='top', wrap_text=True) + wa.row_dimensions[zeile].height = 58 + zeile += 2 + +wa.merge_cells(f'B{zeile}:C{zeile}') +c = wa.cell(row=zeile, column=2, value='Aktueller Portal-Stand: v1.21 (Live-Stammdaten importiert)') +c.font = Font(size=9, italic=True, color='94A3B8') + +out = 'UAT-Featureliste Mieterportal.xlsx' +wb.save(out) +print('gespeichert:', out) diff --git a/server/src/index.js b/server/src/index.js index 6f7094e..6eb822a 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1238,11 +1238,61 @@ app.patch('/api/users/:id', auth, adminOnly, async (req, res, next) => { await pool.query('UPDATE users SET aktiv = $1 WHERE id = $2', [Boolean(b.aktiv), req.params.id]) if (b.einheitId !== undefined) await pool.query('UPDATE users SET einheit_id = $1 WHERE id = $2', [b.einheitId || null, req.params.id]) + if (b.email !== undefined) { + const email = String(b.email || '').trim() + if (email && !/^\S+@\S+\.\S+$/.test(email)) + return res.status(400).json({ fehler: 'Ungültige E-Mail-Adresse.' }) + if (email) { + const { rows: fremde } = await pool.query( + 'SELECT id FROM users WHERE lower(email) = lower($1) AND id <> $2', [email, req.params.id], + ) + if (fremde.length) + return res.status(400).json({ fehler: 'Diese E-Mail-Adresse wird bereits verwendet.' }) + } + await pool.query('UPDATE users SET email = $1 WHERE id = $2', [email, req.params.id]) + } const { rows: neu } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id]) res.json({ benutzer: oeffentlicherBenutzer(neu[0]) }) } catch (e) { next(e) } }) +// Direkte E-Mail der Verwaltung an einen Mieter (immer mit CC an die +// Verwaltung, damit die Kommunikation dort vollständig nachvollziehbar ist) +app.post('/api/users/:id/email', auth, adminOnly, async (req, res, next) => { + try { + const betreff = String((req.body || {}).betreff || '').trim() + const text = String((req.body || {}).text || '').trim() + if (!betreff || !text) + return res.status(400).json({ fehler: 'Betreff und Nachricht erforderlich.' }) + const { rows } = await pool.query( + `SELECT * FROM users WHERE id = $1 AND rolle = 'mieter'`, [req.params.id], + ) + const mieter = rows[0] + if (!mieter) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' }) + if (!mieter.email) + return res.status(400).json({ fehler: `${mieter.name} hat keine E-Mail-Adresse hinterlegt (Profil).` }) + const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [mieter.einheit_id]) + const label = mail.einheitLabel(mapUnit(einheitRows[0])) + const html = mail.baueHtmlMail({ + titel: 'Nachricht der Verwaltung', + untertitel: label, + einleitung: `Persönliche Nachricht von ${req.user.name} (Hinterstein Immobilien):`, + zeilen: [ + ['Wohnung', label], + ['Mieter', mieter.name], + ], + zitat: text, + aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`), + }) + const cc = await mail.verwaltungEmails() + const textVersion = + `Nachricht der Verwaltung an ${mieter.name} (${label}):\n\n„${text}“\n\n` + + `— Gesendet von ${req.user.name} über das Mieterportal` + mail.sendeMail(mieter.email, betreff, textVersion, html, cc) + res.json({ ok: true }) + } catch (e) { next(e) } +}) + // ── Registrierungscodes (nur Verwaltung) ───────────────────── app.get('/api/codes', auth, adminOnly, async (_req, res, next) => { try { diff --git a/server/src/mail.js b/server/src/mail.js index c1f5578..1da690d 100644 --- a/server/src/mail.js +++ b/server/src/mail.js @@ -108,13 +108,17 @@ function buttonHtml(text, url) { return `
` } -async function sendeMail(an, betreff, text, html) { +async function sendeMail(an, betreff, text, html, cc) { if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return try { const res = await fetch(`${MAIL_API_URL}/send`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Api-Token': MAIL_API_TOKEN }, - body: JSON.stringify({ to: an, subject: betreff, text: `${text}\n\n${FUSSZEILE_TEXT}`, ...(html ? { html } : {}) }), + body: JSON.stringify({ + to: an, subject: betreff, text: `${text}\n\n${FUSSZEILE_TEXT}`, + ...(html ? { html } : {}), + ...(cc ? { cc } : {}), // v1.22: Komma-getrennte CC-Empfänger + }), }) if (!res.ok) console.warn(`[mail] Versand an ${an} fehlgeschlagen: HTTP ${res.status}`) } catch (e) { @@ -122,6 +126,14 @@ async function sendeMail(an, betreff, text, html) { } } +/** E-Mail-Adressen aller aktiven Verwaltungsnutzer (komma-getrennt, für CC). */ +async function verwaltungEmails() { + const { rows } = await require('./db').pool.query( + `SELECT email FROM users WHERE rolle = 'verwaltung' AND aktiv AND email <> ''`, + ) + return rows.map((r) => r.email).join(', ') +} + /** Alle aktiven Verwaltungsnutzer benachrichtigen. */ async function benachrichtigeVerwaltung(betreff, text, html) { const { rows } = await require('./db').pool.query( @@ -147,6 +159,7 @@ module.exports = { sendeMail, benachrichtigeVerwaltung, benachrichtigeMieter, + verwaltungEmails, einheitLabel, baueHtmlMail, buttonHtml, diff --git a/src/pages/BenutzerVerwaltung.tsx b/src/pages/BenutzerVerwaltung.tsx index 57397d9..92cdf20 100644 --- a/src/pages/BenutzerVerwaltung.tsx +++ b/src/pages/BenutzerVerwaltung.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { KeyRound, Plus, RefreshCcw, Search, ShieldCheck, UserCheck, UsersRound } from 'lucide-react' +import { KeyRound, Mail, Plus, RefreshCcw, Search, SendHorizonal, ShieldCheck, UserCheck, UsersRound } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { @@ -12,8 +12,9 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' import { useStore } from '../store/store' -import type { BenutzerRolle } from '../types' +import type { Benutzer, BenutzerRolle } from '../types' function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) { const tones = { @@ -130,8 +131,104 @@ function NeuerBenutzerDialog() { ) } +function EmailDialog({ benutzer: b, einheitLabel }: { benutzer: Benutzer; einheitLabel: string }) { + const { sendeBenutzerEmail } = useStore() + const [open, setOpen] = useState(false) + const [betreff, setBetreff] = useState('') + const [text, setText] = useState('') + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState