v1.22: Verwaltung sendet Direkt-E-Mails an Mieter mit CC an die Verwaltung
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -189,6 +189,27 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 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
|
**Erledigt 04.10. (v1.21):** Live-Stammdaten aus SharePoint-Export-CSV
|
||||||
importiert (KEIN Dauersync – Nutzer-Entscheidung: einmaliger Import,
|
importiert (KEIN Dauersync – Nutzer-Entscheidung: einmaliger Import,
|
||||||
Pflege danach nur noch im Portal). CSV „Master Data V" (53 Zeilen,
|
Pflege danach nur noch im Portal). CSV „Master Data V" (53 Zeilen,
|
||||||
|
|||||||
Binary file not shown.
@@ -12,7 +12,8 @@ Endpunkte:
|
|||||||
GET /healthz → {"ok": true}
|
GET /healthz → {"ok": true}
|
||||||
GET /outbox → aktuelle Outbox (Demo/Debug)
|
GET /outbox → aktuelle Outbox (Demo/Debug)
|
||||||
POST /send → {"ok": true, "id": ..., "versandt": true/false}
|
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
|
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:
|
def versenden(e: dict) -> bool:
|
||||||
msg = EmailMessage()
|
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"]
|
msg["To"] = e["to"]
|
||||||
|
if e.get("cc"):
|
||||||
|
msg["Cc"] = e["cc"] # Komma-getrennte CC-Empfaenger (v1.22)
|
||||||
msg["Subject"] = e["subject"]
|
msg["Subject"] = e["subject"]
|
||||||
msg.set_content(e.get("text") or "")
|
msg.set_content(e.get("text") or "")
|
||||||
if e.get("html"):
|
if e.get("html"):
|
||||||
@@ -172,12 +175,26 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
if not rate_limit_ok():
|
if not rate_limit_ok():
|
||||||
self._antwort(429, {"fehler": "Rate-Limit erreicht"})
|
self._antwort(429, {"fehler": "Rate-Limit erreicht"})
|
||||||
return
|
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 = {
|
eintrag = {
|
||||||
"id": uuid.uuid4().hex[:12],
|
"id": uuid.uuid4().hex[:12],
|
||||||
"to": an,
|
"to": an,
|
||||||
"subject": betreff,
|
"subject": betreff,
|
||||||
"text": str(body.get("text", "")),
|
"text": str(body.get("text", "")),
|
||||||
"html": str(body.get("html", "")),
|
"html": str(body.get("html", "")),
|
||||||
|
"cc": cc,
|
||||||
|
"from_address": absender,
|
||||||
|
"from_name": absender_name,
|
||||||
"versuche": 0,
|
"versuche": 0,
|
||||||
"versandt": False,
|
"versandt": False,
|
||||||
"angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"),
|
"angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"),
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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])
|
await pool.query('UPDATE users SET aktiv = $1 WHERE id = $2', [Boolean(b.aktiv), req.params.id])
|
||||||
if (b.einheitId !== undefined)
|
if (b.einheitId !== undefined)
|
||||||
await pool.query('UPDATE users SET einheit_id = $1 WHERE id = $2', [b.einheitId || null, req.params.id])
|
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])
|
const { rows: neu } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])
|
||||||
res.json({ benutzer: oeffentlicherBenutzer(neu[0]) })
|
res.json({ benutzer: oeffentlicherBenutzer(neu[0]) })
|
||||||
} catch (e) { next(e) }
|
} 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) ─────────────────────
|
// ── Registrierungscodes (nur Verwaltung) ─────────────────────
|
||||||
app.get('/api/codes', auth, adminOnly, async (_req, res, next) => {
|
app.get('/api/codes', auth, adminOnly, async (_req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+15
-2
@@ -108,13 +108,17 @@ function buttonHtml(text, url) {
|
|||||||
return `<p style="margin:24px 0 0;"><a href="${htmlEscape(url)}" style="display:inline-block;background:${FARBE_BUTTON};color:${FARBE_BUTTON_TEXT};text-decoration:none;font-size:14px;font-weight:700;padding:12px 22px;border-radius:10px;">${htmlEscape(text)}</a></p>`
|
return `<p style="margin:24px 0 0;"><a href="${htmlEscape(url)}" style="display:inline-block;background:${FARBE_BUTTON};color:${FARBE_BUTTON_TEXT};text-decoration:none;font-size:14px;font-weight:700;padding:12px 22px;border-radius:10px;">${htmlEscape(text)}</a></p>`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendeMail(an, betreff, text, html) {
|
async function sendeMail(an, betreff, text, html, cc) {
|
||||||
if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return
|
if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${MAIL_API_URL}/send`, {
|
const res = await fetch(`${MAIL_API_URL}/send`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Api-Token': MAIL_API_TOKEN },
|
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}`)
|
if (!res.ok) console.warn(`[mail] Versand an ${an} fehlgeschlagen: HTTP ${res.status}`)
|
||||||
} catch (e) {
|
} 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. */
|
/** Alle aktiven Verwaltungsnutzer benachrichtigen. */
|
||||||
async function benachrichtigeVerwaltung(betreff, text, html) {
|
async function benachrichtigeVerwaltung(betreff, text, html) {
|
||||||
const { rows } = await require('./db').pool.query(
|
const { rows } = await require('./db').pool.query(
|
||||||
@@ -147,6 +159,7 @@ module.exports = {
|
|||||||
sendeMail,
|
sendeMail,
|
||||||
benachrichtigeVerwaltung,
|
benachrichtigeVerwaltung,
|
||||||
benachrichtigeMieter,
|
benachrichtigeMieter,
|
||||||
|
verwaltungEmails,
|
||||||
einheitLabel,
|
einheitLabel,
|
||||||
baueHtmlMail,
|
baueHtmlMail,
|
||||||
buttonHtml,
|
buttonHtml,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
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 { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { useStore } from '../store/store'
|
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' }) {
|
function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) {
|
||||||
const tones = {
|
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<string | null>(null)
|
||||||
|
const [gesendet, setGesendet] = useState(false)
|
||||||
|
|
||||||
|
const hatEmail = Boolean(b.email)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!betreff.trim() || !text.trim() || sende) return
|
||||||
|
setSende(true)
|
||||||
|
setFehler(null)
|
||||||
|
const err = await sendeBenutzerEmail(b.id, betreff.trim(), text.trim())
|
||||||
|
setSende(false)
|
||||||
|
if (err) {
|
||||||
|
setFehler(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGesendet(true)
|
||||||
|
setBetreff('')
|
||||||
|
setText('')
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setGesendet(false)
|
||||||
|
setOpen(false)
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-teal-50 px-2.5 py-1.5 text-xs font-medium text-teal-700 transition hover:bg-teal-100 dark:bg-teal-950 dark:text-teal-300 dark:hover:bg-teal-900"
|
||||||
|
title={hatEmail ? `E-Mail an ${b.name}` : `${b.name} hat keine E-Mail-Adresse hinterlegt`}
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5" /> E-Mail
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>E-Mail an {b.name}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Empfänger</Label>
|
||||||
|
<div className="rounded-lg border bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:bg-slate-900 dark:text-slate-300">
|
||||||
|
{b.name} · {hatEmail ? b.email : 'keine E-Mail hinterlegt'}
|
||||||
|
{einheitLabel && <span className="block text-xs text-slate-400">{einheitLabel}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Die Verwaltung erhält automatisch eine Kopie (CC).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="m-betreff">Betreff</Label>
|
||||||
|
<Input
|
||||||
|
id="m-betreff"
|
||||||
|
placeholder="z. B. Terminabsprache Hausmeisterservice"
|
||||||
|
value={betreff}
|
||||||
|
onChange={(e) => setBetreff(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="m-text">Nachricht</Label>
|
||||||
|
<Textarea
|
||||||
|
id="m-text"
|
||||||
|
rows={6}
|
||||||
|
placeholder="Ihre Nachricht an den Mieter …"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{fehler && (
|
||||||
|
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
|
||||||
|
{fehler}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gesendet && (
|
||||||
|
<div className="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
|
||||||
|
E-Mail wurde versendet (mit CC an die Verwaltung).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Schließen</Button>
|
||||||
|
<Button onClick={submit} disabled={!betreff.trim() || !text.trim() || sende || !hatEmail} className="gap-2">
|
||||||
|
<SendHorizonal className="h-4 w-4" /> {sende ? 'Wird gesendet …' : 'Senden'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function BenutzerVerwaltung() {
|
export default function BenutzerVerwaltung() {
|
||||||
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, generiereCodes } = useStore()
|
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, setzeBenutzerEmail, generiereCodes } = useStore()
|
||||||
const [suche, setSuche] = useState('')
|
const [suche, setSuche] = useState('')
|
||||||
const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle')
|
const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle')
|
||||||
const [anzahl, setAnzahl] = useState(5)
|
const [anzahl, setAnzahl] = useState(5)
|
||||||
@@ -320,10 +417,23 @@ export default function BenutzerVerwaltung() {
|
|||||||
{!b.aktiv && <Badge variant="secondary" className="bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300">deaktiviert</Badge>}
|
{!b.aktiv && <Badge variant="secondary" className="bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300">deaktiviert</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-slate-500">
|
<div className="truncate text-xs text-slate-500">
|
||||||
@{b.benutzername} · {b.email}
|
@{b.benutzername}
|
||||||
{einheit && ` · ${einheit.building}, Whg. ${einheit.number}`}
|
{einheit && ` · ${einheit.building}, Whg. ${einheit.number}`}
|
||||||
{b.letzterLogin && ` · zuletzt: ${b.letzterLogin}`}
|
{b.letzterLogin && ` · zuletzt: ${b.letzterLogin}`}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-1.5">
|
||||||
|
<Mail className="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
||||||
|
<input
|
||||||
|
defaultValue={b.email}
|
||||||
|
placeholder="E-Mail-Adresse …"
|
||||||
|
aria-label={`E-Mail für ${b.name}`}
|
||||||
|
className="h-6 w-60 rounded border border-transparent bg-transparent px-1.5 text-xs text-slate-600 transition hover:border-slate-200 focus:border-teal-400 focus:outline-none dark:text-slate-300 dark:hover:border-slate-700"
|
||||||
|
onBlur={(e) => {
|
||||||
|
const wert = e.target.value.trim()
|
||||||
|
if (wert !== b.email) setzeBenutzerEmail(b.id, wert)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{b.rolle === 'mieter' && (
|
{b.rolle === 'mieter' && (
|
||||||
<select
|
<select
|
||||||
@@ -341,6 +451,12 @@ export default function BenutzerVerwaltung() {
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
|
{b.rolle === 'mieter' && (
|
||||||
|
<EmailDialog
|
||||||
|
benutzer={b}
|
||||||
|
einheitLabel={einheit ? `${einheit.building}, Whg. ${einheit.number}` : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => passwortZuruecksetzen(b.id, b.name)}
|
onClick={() => passwortZuruecksetzen(b.id, b.name)}
|
||||||
className="rounded-lg bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
className="rounded-lg bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ interface Store extends Bootstrap {
|
|||||||
setAktiv: (id: string, aktiv: boolean) => Promise<void>
|
setAktiv: (id: string, aktiv: boolean) => Promise<void>
|
||||||
setPasswort: (id: string, passwort: string) => Promise<void>
|
setPasswort: (id: string, passwort: string) => Promise<void>
|
||||||
weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
|
weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
|
||||||
|
/** Direkt-E-Mail der Verwaltung an einen Mieter (immer mit CC an die Verwaltung, v1.22) */
|
||||||
|
sendeBenutzerEmail: (id: string, betreff: string, text: string) => Promise<string | null>
|
||||||
|
/** E-Mail-Adresse eines Benutzers pflegen (Verwaltung, v1.22) */
|
||||||
|
setzeBenutzerEmail: (id: string, email: string) => Promise<string | null>
|
||||||
generiereCodes: (anzahl: number, einheitId: string | null) => Promise<RegistrierungsCode[]>
|
generiereCodes: (anzahl: number, einheitId: string | null) => Promise<RegistrierungsCode[]>
|
||||||
// ── Zählerstände (Verwaltung pflegt, Mieter lesen) ──────
|
// ── Zählerstände (Verwaltung pflegt, Mieter lesen) ──────
|
||||||
addZaehler: (z: {
|
addZaehler: (z: {
|
||||||
@@ -344,6 +348,23 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { einheitId })
|
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { einheitId })
|
||||||
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
|
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
|
||||||
},
|
},
|
||||||
|
async sendeBenutzerEmail(id, betreff, text) {
|
||||||
|
try {
|
||||||
|
await apiPost(`/api/users/${id}/email`, { betreff, text })
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
return fehlerNachricht(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setzeBenutzerEmail(id, email) {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { email })
|
||||||
|
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
return fehlerNachricht(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
async generiereCodes(anzahl, einheitId) {
|
async generiereCodes(anzahl, einheitId) {
|
||||||
const { codes } = await apiPost<{ codes: RegistrierungsCode[] }>('/api/codes', { anzahl, einheitId })
|
const { codes } = await apiPost<{ codes: RegistrierungsCode[] }>('/api/codes', { anzahl, einheitId })
|
||||||
await ladeAlles()
|
await ladeAlles()
|
||||||
|
|||||||
Reference in New Issue
Block a user