v1.2: E-Mail-Benachrichtigungen an Portal angeschlossen
- nginx-Proxy POST /api/mail/send -> mp-mail-api:8080 (nur POST, 64k Limit) - Token serverseitig via envsubst-Template + portal.env (gitignored, nur Server) - src/lib/mail.ts: fire-and-forget Versand, Empfaenger-Aufloesung (Verwaltung/Mieter) - Benachrichtigung: neue Schadensmeldung -> Verwaltung, Nachrichten/Antworten -> Gegenstelle - E2E live verifiziert (versandt: true an axel@hocks.eu), GET 403 - Doku (kimi.md, README) nachgefuehrt
This commit is contained in:
@@ -25,6 +25,7 @@ dist-ssr
|
|||||||
|
|
||||||
# Projekt-spezifisch: Secrets und lokale Artefakte
|
# Projekt-spezifisch: Secrets und lokale Artefakte
|
||||||
mail-server/mail.env
|
mail-server/mail.env
|
||||||
|
portal.env
|
||||||
mail-url.txt
|
mail-url.txt
|
||||||
mail-vorschau*.html
|
mail-vorschau*.html
|
||||||
tmp-*
|
tmp-*
|
||||||
|
|||||||
@@ -41,8 +41,16 @@ Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und startet den nginx-Conta
|
|||||||
|
|
||||||
Alternativ lokal ohne Server: `docker build -t mieterportal . && docker run -p 8080:80 mieterportal`.
|
Alternativ lokal ohne Server: `docker build -t mieterportal . && docker run -p 8080:80 mieterportal`.
|
||||||
|
|
||||||
## Optional: Mail-Stack
|
## Mail-Stack & Benachrichtigungen
|
||||||
|
|
||||||
`mail-server/` enthält den aus dem Schulportal übernommenen Aufbau: Postfix-Relay
|
`mail-server/` enthält den aus dem Schulportal übernommenen Aufbau: Postfix-Relay
|
||||||
(nur intern, kein offenes Relay) + schlanke Python-API. Konfiguration über
|
(nur intern, kein offenes Relay) + schlanke Python-API. Konfiguration über
|
||||||
`mail-server/mail.env` (Vorlage: `mail.env.example`), **nicht** im Repo.
|
`mail-server/mail.env` (Vorlage: `mail.env.example`), **nicht** im Repo.
|
||||||
|
|
||||||
|
Auf dem Server läuft der Stack unter `/opt/mp-mail`. Das Portal ist verkabelt:
|
||||||
|
Der nginx des `mp-portal`-Containers proxiet ausschließlich `POST /api/mail/send`
|
||||||
|
an die Mail-API und authentifiziert sich dabei serverseitig mit dem Token aus
|
||||||
|
`portal.env` (nur auf dem Server, gitignored). Das Frontend benachrichtigt die
|
||||||
|
Verwaltung bei neuen Schadensmeldungen und die jeweilige Gegenstelle bei
|
||||||
|
Nachrichten – Versand fire-and-forget, ein Ausfall der Mail-API beeinträchtigt
|
||||||
|
das Portal nicht.
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ echo "==> 1/4 Frontend bauen"
|
|||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
echo "==> 2/4 Paket erstellen und hochladen"
|
echo "==> 2/4 Paket erstellen und hochladen"
|
||||||
rm -rf tmp-deploy && mkdir -p tmp-deploy/docker
|
rm -rf tmp-deploy && mkdir -p tmp-deploy/docker/templates
|
||||||
cp -r dist tmp-deploy/dist
|
cp -r dist tmp-deploy/dist
|
||||||
cp docker-compose.yml tmp-deploy/
|
cp docker-compose.yml tmp-deploy/
|
||||||
cp docker/nginx.conf tmp-deploy/docker/
|
cp docker/templates/default.conf.template tmp-deploy/docker/templates/
|
||||||
tar -czf mp-dist.tar.gz -C tmp-deploy .
|
tar -czf mp-dist.tar.gz -C tmp-deploy .
|
||||||
rm -rf tmp-deploy
|
rm -rf tmp-deploy
|
||||||
"$PSCP" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" \
|
"$PSCP" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" \
|
||||||
|
|||||||
+4
-1
@@ -9,7 +9,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- ./dist:/usr/share/nginx/html:ro
|
- ./dist:/usr/share/nginx/html:ro
|
||||||
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
# envsubst ersetzt ${MAIL_API_TOKEN} beim Start aus portal.env
|
||||||
|
- ./docker/templates/default.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||||
|
env_file:
|
||||||
|
- portal.env
|
||||||
networks:
|
networks:
|
||||||
- npm_default
|
- npm_default
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name mieterportal.hocks.eu localhost _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript image/svg+xml application/json;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# HTML nie cachen (Assets sind hash-benannt → ein Reload genügt)
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache für gehashte Build-Assets
|
||||||
|
location /assets/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mail-API des Portals (Container mp-mail-api im selben Docker-Netz npm_default).
|
||||||
|
# Das Token kommt aus portal.env (envsubst beim Container-Start) und liegt
|
||||||
|
# NIE im Repo und NIE im Browser – der Endpunkt ist nur vom Server erreichbar.
|
||||||
|
location = /api/mail/send {
|
||||||
|
limit_except POST { deny all; }
|
||||||
|
proxy_pass http://mp-mail-api:8080/send;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Api-Token "${MAIL_API_TOKEN}";
|
||||||
|
proxy_set_header X-Request-Source mieterportal-nginx;
|
||||||
|
client_max_body_size 64k;
|
||||||
|
proxy_read_timeout 15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA-Fallback für React Router
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: `MP-2026-08
|
|||||||
| Auth | `src/lib/auth.ts` (SHA-256, Session 8 h, Keys `mp_benutzer`/`mp_benutzer_session`), Route-Guard in `App.tsx` |
|
| Auth | `src/lib/auth.ts` (SHA-256, Session 8 h, Keys `mp_benutzer`/`mp_benutzer_session`), Route-Guard in `App.tsx` |
|
||||||
| Branding | Kunde: `src/assets/hinterstein/` (logo.png, joseph-hinterstein.jpg, kontakt.jpg); KI-generiert: `src/assets/logo.png`, `hero.jpg`, `modules.jpg` |
|
| Branding | Kunde: `src/assets/hinterstein/` (logo.png, joseph-hinterstein.jpg, kontakt.jpg); KI-generiert: `src/assets/logo.png`, `hero.jpg`, `modules.jpg` |
|
||||||
| Webserver (Ziel) | Docker-Container `mp-portal` (nginx), statisches `dist/`, gemountet `/opt/mieterportal/dist` |
|
| Webserver (Ziel) | Docker-Container `mp-portal` (nginx), statisches `dist/`, gemountet `/opt/mieterportal/dist` |
|
||||||
| Mail | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`) – **live auf dem Server** unter `/opt/mp-mail` |
|
| Mail | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`) – **live**, `/opt/mp-mail` auf dem Server; **verkabelt**: Portal-nginx proxyt `POST /api/mail/send` → `mp-mail-api:8080` und injiziert das Token aus `portal.env` (serverseitig, nie im Browser/Repo) |
|
||||||
| Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) |
|
| Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) |
|
||||||
|
|
||||||
## 3. Wichtige Dateien
|
## 3. Wichtige Dateien
|
||||||
@@ -68,6 +68,8 @@ ausgesperrt via `.gitignore` (`mail-server/mail.env` etc.).
|
|||||||
| `src/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) |
|
| `src/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) |
|
||||||
| `src/lib/theme.ts` | `mp-theme` in localStorage, Init vor erstem Render (main.tsx) |
|
| `src/lib/theme.ts` | `mp-theme` in localStorage, Init vor erstem Render (main.tsx) |
|
||||||
| `src/pages/Login.tsx` | Login-Pflicht: Hero mit Joseph-Hinterstein-Portrait, Logo, Feature-Liste, Login-Formular + Registrierung-Link + Demo-Einstiege |
|
| `src/pages/Login.tsx` | Login-Pflicht: Hero mit Joseph-Hinterstein-Portrait, Logo, Feature-Liste, Login-Formular + Registrierung-Link + Demo-Einstiege |
|
||||||
|
| `src/lib/mail.ts` | E-Mail-Benachrichtigungen: `sendeBenachrichtigung` (fire-and-forget) an `/api/mail/send`, Empfänger-Auflösung (Verwaltung / Mieter je Wohnung) |
|
||||||
|
| `docker/templates/default.conf.template` | nginx-Site als envsubst-Template; proxiet `POST /api/mail/send` → `mp-mail-api:8080` mit `${MAIL_API_TOKEN}` aus `portal.env` |
|
||||||
| `src/pages/Benutzer.tsx` | Benutzerverwaltung (nur Verwaltung): Nutzer, Gruppen, Registrierungscodes |
|
| `src/pages/Benutzer.tsx` | Benutzerverwaltung (nur Verwaltung): Nutzer, Gruppen, Registrierungscodes |
|
||||||
| `Dockerfile` | Optionaler All-in-One-Build (node build → nginx) für `docker build`-Workflow |
|
| `Dockerfile` | Optionaler All-in-One-Build (node build → nginx) für `docker build`-Workflow |
|
||||||
| `docker/nginx.conf` | nginx-Site: SPA-Fallback, Gzip, Cache-Header für /assets/, Security-Header |
|
| `docker/nginx.conf` | nginx-Site: SPA-Fallback, Gzip, Cache-Header für /assets/, Security-Header |
|
||||||
@@ -101,6 +103,10 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
```
|
```
|
||||||
|
|
||||||
- SSH: Port **38592**, Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk` (Pageant), Hostkey-Flag zwingend.
|
- SSH: Port **38592**, Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk` (Pageant), Hostkey-Flag zwingend.
|
||||||
|
- **`portal.env` auf dem Server wird vom compose als `env_file` gebraucht**
|
||||||
|
(liefert `MAIL_API_TOKEN` für den nginx-envsubst). Einmalig angelegt:
|
||||||
|
`grep '^MAIL_API_TOKEN=' /opt/mp-mail/mail.env > /opt/mieterportal/portal.env`
|
||||||
|
Liegt nur auf dem Server (gitignored), wird von deploy.sh nicht hochgeladen.
|
||||||
- **Vorsicht Fail2Ban:** zu viele gescheiterte SSH-Versuche von einer IP führen zu einem
|
- **Vorsicht Fail2Ban:** zu viele gescheiterte SSH-Versuche von einer IP führen zu einem
|
||||||
Ban (Timeout-Verbindungsbild). Ban aufheben auf dem Server: `fail2ban-client unban --all`.
|
Ban (Timeout-Verbindungsbild). Ban aufheben auf dem Server: `fail2ban-client unban --all`.
|
||||||
- **Live (seit 02.10.2026):** Container `mp-portal` läuft auf hocks.eu, statisches `dist/`
|
- **Live (seit 02.10.2026):** Container `mp-portal` läuft auf hocks.eu, statisches `dist/`
|
||||||
@@ -126,7 +132,17 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
- **Mail-Stack live (seit 02.10.2026):** Container `mp-mail` + `mp-mail-api` laufen
|
- **Mail-Stack live (seit 02.10.2026):** Container `mp-mail` + `mp-mail-api` laufen
|
||||||
auf hocks.eu unter `/opt/mp-mail` (Relay über `hocks-eu.mail.protection.outlook.com`).
|
auf hocks.eu unter `/opt/mp-mail` (Relay über `hocks-eu.mail.protection.outlook.com`).
|
||||||
Testmail und API-`/send` (mit `X-Api-Token`) verifiziert (status=sent); ohne Token 401.
|
Testmail und API-`/send` (mit `X-Api-Token`) verifiziert (status=sent); ohne Token 401.
|
||||||
Noch nicht mit dem Portal-Frontend verkabelt (keine automatischen Benachrichtigungen).
|
- **Mail-Benachrichtigungen im Portal (seit 02.10., v1.2):** Der Portal-nginx
|
||||||
|
(`docker/templates/default.conf.template`, envsubst) proxiet ausschließlich
|
||||||
|
`POST /api/mail/send` an `http://mp-mail-api:8080/send` und setzt das Token
|
||||||
|
aus `/opt/mieterportal/portal.env` (chmod 600, **nicht im Repo**, gitignored).
|
||||||
|
Das Frontend (`src/lib/mail.ts`) sendet fire-and-forget: neue Schadensmeldung →
|
||||||
|
alle aktiven Verwaltungsbenutzer, neue Nachricht/Antwort → Gegenstelle
|
||||||
|
(Mieter ↔ Verwaltung, Empfänger über Wohnungsbezug). E2E verifiziert:
|
||||||
|
POST über `https://mieterportal.hocks.eu/api/mail/send` → `versandt: true`
|
||||||
|
(Test an axel@hocks.eu); GET wird mit 403 blockiert. Demo-Empfängeradressen
|
||||||
|
(z. B. joseph@hinterstein-immo.de) sind fiktiv → dort kein Empfang möglich,
|
||||||
|
echte Adressen in der Benutzerverwaltung pflegen.
|
||||||
|
|
||||||
## 6. Konventionen & Besonderheiten
|
## 6. Konventionen & Besonderheiten
|
||||||
|
|
||||||
@@ -174,11 +190,16 @@ aktiviert (`/opt/mp-mail`, Testmail + API-/send verifiziert). Store-Key auf
|
|||||||
`hinterstein-mieterportal-v2` gehoben. Live deployed und geprüft (HTTP 200, Titel
|
`hinterstein-mieterportal-v2` gehoben. Live deployed und geprüft (HTTP 200, Titel
|
||||||
„Hinterstein Immobilien · Mieterportal").
|
„Hinterstein Immobilien · Mieterportal").
|
||||||
|
|
||||||
|
**Erledigt 02.10. (v1.2 live):** E-Mail-Benachrichtigungen an das Portal
|
||||||
|
angeschlossen: nginx-Proxy `/api/mail/send` mit serverseitig injiziertem Token
|
||||||
|
(envsubst-Template + `portal.env` auf dem Server), `src/lib/mail.ts` sendet bei
|
||||||
|
neuen Schadensmeldungen (→ Verwaltung) und Nachrichten/Antworten (→ Gegenstelle).
|
||||||
|
E2E-Versand live verifiziert (versandt: true), GET auf den Endpunkt 403.
|
||||||
|
|
||||||
**Offen:**
|
**Offen:**
|
||||||
- Zertifikatserneuerung prüfen (per 01.12.2026): NPM-Renewer sollte cert 9 über HTTP-Challenge
|
- Zertifikatserneuerung prüfen (per 01.12.2026): NPM-Renewer sollte cert 9 über HTTP-Challenge
|
||||||
erneuern; sonst certbot/webroot + Kopie nach npm-9 wiederholen.
|
erneuern; sonst certbot/webroot + Kopie nach npm-9 wiederholen.
|
||||||
- Portal-Frontend an Mail-API verkabeln: automatische E-Mail an die Verwaltung bei neuen
|
- Demo-Empfängeradressen durch echte ersetzen, sobald bekannt.
|
||||||
Schadensmeldungen und an Mieter bei Antworten.
|
|
||||||
- Mögliche nächste Schritte: serverseitige Datenbank (PostgreSQL/Node-API) statt localStorage,
|
- Mögliche nächste Schritte: serverseitige Datenbank (PostgreSQL/Node-API) statt localStorage,
|
||||||
Mehrseitigkeit für 50 echte Mietverhältnisse.
|
Mehrseitigkeit für 50 echte Mietverhältnisse.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/** E-Mail-Benachrichtigungen über die Portal-Mail-API.
|
||||||
|
*
|
||||||
|
* Der Browser ruft nur denselben Ursprung an: POST /api/mail/send wird vom
|
||||||
|
* Portal-nginx (Container mp-portal) an die Mail-API (mp-mail-api:8080)
|
||||||
|
* weitergereicht und dabei serverseitig mit dem API-Token authentifiziert.
|
||||||
|
* Das Token liegt also weder im Repo noch im JavaScript.
|
||||||
|
*
|
||||||
|
* Versand ist bewusst „fire-and-forget": Klappt der Versand nicht (Offline,
|
||||||
|
* API down), bleibt die Portal-Aktion trotzdem erhalten – der Fehler landet
|
||||||
|
* nur in der Browser-Konsole.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Benutzer } from '../types'
|
||||||
|
|
||||||
|
const ENDPUNKT = '/api/mail/send'
|
||||||
|
|
||||||
|
export async function sendeMail(an: string, betreff: string, text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(ENDPUNKT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ to: an, subject: betreff, text }),
|
||||||
|
})
|
||||||
|
return res.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget an eine oder mehrere Adressen (null/leer = nichts senden). */
|
||||||
|
export function sendeBenachrichtigung(an: string | string[] | null | undefined, betreff: string, text: string) {
|
||||||
|
const adressen = (Array.isArray(an) ? an : an ? [an] : []).filter(Boolean)
|
||||||
|
for (const adresse of adressen) {
|
||||||
|
sendeMail(adresse, betreff, text).then((ok) => {
|
||||||
|
if (!ok) console.warn(`[mail] Versand an ${adresse} fehlgeschlagen`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FUSSZEILE =
|
||||||
|
'\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien'
|
||||||
|
|
||||||
|
export function nachrichtenText(koerper: string): string {
|
||||||
|
return `${koerper}${FUSSZEILE}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E-Mail-Adressen aller aktiven Verwaltungsbenutzer. */
|
||||||
|
export function verwaltungsAdressen(benutzer: Benutzer[]): string[] {
|
||||||
|
return benutzer
|
||||||
|
.filter((b) => b.rolle === 'verwaltung' && b.aktiv && b.email)
|
||||||
|
.map((b) => b.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E-Mail-Adressen der Mieter einer bestimmten Wohnung. */
|
||||||
|
export function mieterAdressenFuerEinheit(benutzer: Benutzer[], einheitId: string): string[] {
|
||||||
|
return benutzer
|
||||||
|
.filter((b) => b.rolle === 'mieter' && b.aktiv && b.einheitId === einheitId && b.email)
|
||||||
|
.map((b) => b.email)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename einer Wohnung, z. B. „Hauptstraße 12, Whg. 7". */
|
||||||
|
export function einheitLabel(einheit: { building: string; number: string }): string {
|
||||||
|
return `${einheit.building}, Whg. ${einheit.number}`
|
||||||
|
}
|
||||||
+52
-7
@@ -13,9 +13,30 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useStore } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
|
import {
|
||||||
|
sendeBenachrichtigung,
|
||||||
|
verwaltungsAdressen,
|
||||||
|
mieterAdressenFuerEinheit,
|
||||||
|
nachrichtenText,
|
||||||
|
} from '../lib/mail'
|
||||||
|
|
||||||
|
/** Gegenstelle benachrichtigen: Mieter → Verwaltung, Verwaltung → Mieter der Wohnung. */
|
||||||
|
function benachrichtigeGegenstelle(opts: {
|
||||||
|
benutzer: ReturnType<typeof useStore>['benutzer']
|
||||||
|
vonRolle: 'tenant' | 'admin'
|
||||||
|
einheitId: string
|
||||||
|
betreff: string
|
||||||
|
text: string
|
||||||
|
}) {
|
||||||
|
const adressen =
|
||||||
|
opts.vonRolle === 'tenant'
|
||||||
|
? verwaltungsAdressen(opts.benutzer)
|
||||||
|
: mieterAdressenFuerEinheit(opts.benutzer, opts.einheitId)
|
||||||
|
sendeBenachrichtigung(adressen, opts.betreff, nachrichtenText(opts.text))
|
||||||
|
}
|
||||||
|
|
||||||
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
||||||
const { dispatch, role, me, meineEinheit } = useStore()
|
const { dispatch, role, me, meineEinheit, benutzer } = useStore()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [subject, setSubject] = useState('')
|
const [subject, setSubject] = useState('')
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
@@ -25,12 +46,14 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!subject.trim() || !text.trim()) return
|
if (!subject.trim() || !text.trim()) return
|
||||||
const id = `m-${Date.now()}`
|
const id = `m-${Date.now()}`
|
||||||
|
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
|
||||||
|
const autor = role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'newThread',
|
type: 'newThread',
|
||||||
thread: {
|
thread: {
|
||||||
id,
|
id,
|
||||||
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
|
unitId,
|
||||||
tenant: role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName,
|
tenant: autor,
|
||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
unreadByAdmin: true,
|
unreadByAdmin: true,
|
||||||
unreadByTenant: false,
|
unreadByTenant: false,
|
||||||
@@ -38,13 +61,23 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
{
|
{
|
||||||
id: `${id}-1`,
|
id: `${id}-1`,
|
||||||
from: role,
|
from: role,
|
||||||
author: role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName,
|
author: autor,
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
at: new Date().toLocaleString('de-DE'),
|
at: new Date().toLocaleString('de-DE'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
benachrichtigeGegenstelle({
|
||||||
|
benutzer,
|
||||||
|
vonRolle: role,
|
||||||
|
einheitId: unitId,
|
||||||
|
betreff:
|
||||||
|
role === 'tenant'
|
||||||
|
? `Neue Nachricht im Mieterportal: ${subject.trim()}`
|
||||||
|
: `Nachricht der Verwaltung: ${subject.trim()}`,
|
||||||
|
text: `Neue Unterhaltung von ${autor}:\n\n„${text.trim()}“`,
|
||||||
|
})
|
||||||
setSubject('')
|
setSubject('')
|
||||||
setText('')
|
setText('')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
@@ -84,7 +117,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Messages() {
|
export default function Messages() {
|
||||||
const { threads, role, dispatch, units, me, meineEinheit } = useStore()
|
const { threads, role, dispatch, units, me, meineEinheit, benutzer } = useStore()
|
||||||
const meinName = me?.name ?? 'Mieter'
|
const meinName = me?.name ?? 'Mieter'
|
||||||
const verwaltungsName = 'Joseph Hinterstein'
|
const verwaltungsName = 'Joseph Hinterstein'
|
||||||
const visible = role === 'tenant'
|
const visible = role === 'tenant'
|
||||||
@@ -103,17 +136,29 @@ export default function Messages() {
|
|||||||
|
|
||||||
const send = () => {
|
const send = () => {
|
||||||
if (!active || !draft.trim()) return
|
if (!active || !draft.trim()) return
|
||||||
|
const text = draft.trim()
|
||||||
|
const autor = role === 'tenant' ? meinName : verwaltungsName
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'addMessage',
|
type: 'addMessage',
|
||||||
threadId: active.id,
|
threadId: active.id,
|
||||||
message: {
|
message: {
|
||||||
id: `${active.id}-${Date.now()}`,
|
id: `${active.id}-${Date.now()}`,
|
||||||
from: role,
|
from: role,
|
||||||
author: role === 'tenant' ? meinName : verwaltungsName,
|
author: autor,
|
||||||
text: draft.trim(),
|
text,
|
||||||
at: new Date().toLocaleString('de-DE'),
|
at: new Date().toLocaleString('de-DE'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
benachrichtigeGegenstelle({
|
||||||
|
benutzer,
|
||||||
|
vonRolle: role,
|
||||||
|
einheitId: active.unitId,
|
||||||
|
betreff:
|
||||||
|
role === 'tenant'
|
||||||
|
? `Neue Antwort im Mieterportal: ${active.subject}`
|
||||||
|
: `Antwort der Verwaltung: ${active.subject}`,
|
||||||
|
text: `Neue Nachricht von ${autor} in der Unterhaltung „${active.subject}“:\n\n„${text}“`,
|
||||||
|
})
|
||||||
setDraft('')
|
setDraft('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-3
@@ -13,6 +13,12 @@ import {
|
|||||||
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 { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
sendeBenachrichtigung,
|
||||||
|
verwaltungsAdressen,
|
||||||
|
nachrichtenText,
|
||||||
|
einheitLabel,
|
||||||
|
} from '../lib/mail'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -46,7 +52,7 @@ const categories = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function NewTicketDialog() {
|
function NewTicketDialog() {
|
||||||
const { dispatch, role, me, meineEinheit } = useStore()
|
const { dispatch, role, me, meineEinheit, benutzer, units } = useStore()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [category, setCategory] = useState('Sanitär')
|
const [category, setCategory] = useState('Sanitär')
|
||||||
@@ -55,12 +61,15 @@ function NewTicketDialog() {
|
|||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!title.trim()) return
|
if (!title.trim()) return
|
||||||
|
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
|
||||||
|
const einheit = units.find((u) => u.id === unitId) ?? meineEinheit
|
||||||
|
const mieterName = role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)'
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'addTicket',
|
type: 'addTicket',
|
||||||
ticket: {
|
ticket: {
|
||||||
id: `t-${Date.now()}`,
|
id: `t-${Date.now()}`,
|
||||||
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
|
unitId,
|
||||||
tenant: role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)',
|
tenant: mieterName,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || '–',
|
description: description.trim() || '–',
|
||||||
category,
|
category,
|
||||||
@@ -69,6 +78,19 @@ function NewTicketDialog() {
|
|||||||
createdAt: new Date().toLocaleDateString('de-DE'),
|
createdAt: new Date().toLocaleDateString('de-DE'),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// Verwaltung per E-Mail benachrichtigen (fire-and-forget)
|
||||||
|
sendeBenachrichtigung(
|
||||||
|
verwaltungsAdressen(benutzer),
|
||||||
|
`Neue Schadensmeldung: ${title.trim()} (${einheitLabel(einheit)})`,
|
||||||
|
nachrichtenText(
|
||||||
|
`Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` +
|
||||||
|
`Wohnung: ${einheitLabel(einheit)}\n` +
|
||||||
|
`Meldung von: ${mieterName}\n` +
|
||||||
|
`Kategorie: ${category}\n` +
|
||||||
|
`Dringlichkeit: ${priority}\n\n` +
|
||||||
|
`${title.trim()}\n${description.trim() || '–'}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setDescription('')
|
setDescription('')
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
|
|||||||
Reference in New Issue
Block a user