feat: Benachrichtigungen in den Einstellungen bedienen
Anmelden dieses Browsers, Liste der angemeldeten Geraete, Auswahl der Anlaesse, Ruhezeit und Vorlauf. Ist ein Weg auf dem Server nicht eingerichtet, steht das direkt daneben statt in einem Fehler beim Speichern. Der Knopf "Probe senden" umgeht Ruhezeit und Sperre absichtlich: wer darauf drueckt, will jetzt wissen, ob es ankommt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e0e11c85b2
commit
b622c1bb28
@@ -0,0 +1,439 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState, useTransition } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Bell, BellOff, Loader2, Send, Trash2 } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Kennzeichen } from '@/components/ui/kennzeichen'
|
||||||
|
import {
|
||||||
|
ANLAESSE,
|
||||||
|
ANLASS_BESCHREIBUNG,
|
||||||
|
ANLASS_NAME,
|
||||||
|
type Anlass,
|
||||||
|
} from '@/lib/benachrichtigung'
|
||||||
|
import {
|
||||||
|
einstellungSpeichern,
|
||||||
|
geraetEntfernen,
|
||||||
|
probeSenden,
|
||||||
|
type Rueckmeldung,
|
||||||
|
} from '@/server/aktionen/benachrichtigungen'
|
||||||
|
|
||||||
|
export type GeraetAnzeige = {
|
||||||
|
id: string
|
||||||
|
bezeichnung: string | null
|
||||||
|
erstelltAm: string
|
||||||
|
zuletztBenutzt: string | null
|
||||||
|
/** Dieses Gerät ist der Browser, in dem die Seite gerade offen ist. */
|
||||||
|
diesesGeraet?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EinstellungAnzeige = {
|
||||||
|
anlaesse: Anlass[]
|
||||||
|
perPush: boolean
|
||||||
|
perMail: boolean
|
||||||
|
ueberblickUm: string
|
||||||
|
ruheVon: string
|
||||||
|
ruheBis: string
|
||||||
|
ruheAmWochenende: boolean
|
||||||
|
zeitblockVorlaufMinuten: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Eigenschaften = {
|
||||||
|
einstellung: EinstellungAnzeige
|
||||||
|
geraete: GeraetAnzeige[]
|
||||||
|
pushMoeglich: boolean
|
||||||
|
mailMoeglich: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt den base64url-Schlüssel in die Form, die der Browser erwartet.
|
||||||
|
*
|
||||||
|
* Der Puffer wird ausdrücklich angelegt, damit der Typ `Uint8Array<ArrayBuffer>`
|
||||||
|
* lautet — `pushManager.subscribe` nimmt keinen geteilten Puffer an.
|
||||||
|
*/
|
||||||
|
function schluesselAlsBytes(base64url: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const fuellung = '='.repeat((4 - (base64url.length % 4)) % 4)
|
||||||
|
const base64 = (base64url + fuellung).replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const roh = atob(base64)
|
||||||
|
const bytes = new Uint8Array(new ArrayBuffer(roh.length))
|
||||||
|
for (let i = 0; i < roh.length; i += 1) bytes[i] = roh.charCodeAt(i)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Benachrichtigungen({
|
||||||
|
einstellung,
|
||||||
|
geraete,
|
||||||
|
pushMoeglich,
|
||||||
|
mailMoeglich,
|
||||||
|
}: Eigenschaften) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [laeuft, starte] = useTransition()
|
||||||
|
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||||
|
const [pushZustand, setPushZustand] = useState<
|
||||||
|
'unbekannt' | 'nicht-unterstuetzt' | 'aus' | 'an' | 'verweigert'
|
||||||
|
>('unbekannt')
|
||||||
|
const [pushLaeuft, setPushLaeuft] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let abgebrochen = false
|
||||||
|
|
||||||
|
async function zustandPruefen() {
|
||||||
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||||
|
if (!abgebrochen) setPushZustand('nicht-unterstuetzt')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Notification.permission === 'denied') {
|
||||||
|
if (!abgebrochen) setPushZustand('verweigert')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const registrierung = await navigator.serviceWorker.getRegistration()
|
||||||
|
const abo = await registrierung?.pushManager.getSubscription()
|
||||||
|
if (!abgebrochen) setPushZustand(abo ? 'an' : 'aus')
|
||||||
|
}
|
||||||
|
|
||||||
|
void zustandPruefen()
|
||||||
|
return () => {
|
||||||
|
abgebrochen = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function mitRueckmeldung(tun: () => Promise<Rueckmeldung>) {
|
||||||
|
starte(async () => {
|
||||||
|
const antwort = await tun()
|
||||||
|
if (antwort.ok) {
|
||||||
|
setFelder({})
|
||||||
|
toast.success(antwort.meldung)
|
||||||
|
router.refresh()
|
||||||
|
} else {
|
||||||
|
setFelder(antwort.felder ?? {})
|
||||||
|
toast.error(antwort.fehler)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushAnmelden() {
|
||||||
|
setPushLaeuft(true)
|
||||||
|
try {
|
||||||
|
const erlaubnis = await Notification.requestPermission()
|
||||||
|
if (erlaubnis !== 'granted') {
|
||||||
|
setPushZustand(erlaubnis === 'denied' ? 'verweigert' : 'aus')
|
||||||
|
toast.error('Ohne Erlaubnis des Browsers geht es nicht.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const antwort = await fetch('/api/push')
|
||||||
|
const daten = (await antwort.json()) as {
|
||||||
|
aktiv: boolean
|
||||||
|
oeffentlicherSchluessel: string | null
|
||||||
|
}
|
||||||
|
if (!daten.aktiv || !daten.oeffentlicherSchluessel) {
|
||||||
|
toast.error('Push ist auf diesem Server nicht eingerichtet.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const registrierung = await navigator.serviceWorker.register('/sw.js')
|
||||||
|
await navigator.serviceWorker.ready
|
||||||
|
|
||||||
|
const abo = await registrierung.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: schluesselAlsBytes(daten.oeffentlicherSchluessel),
|
||||||
|
})
|
||||||
|
|
||||||
|
const roh = abo.toJSON() as { endpoint?: string; keys?: Record<string, string> }
|
||||||
|
const gespeichert = await fetch('/api/push', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpunkt: roh.endpoint,
|
||||||
|
p256dh: roh.keys?.p256dh,
|
||||||
|
auth: roh.keys?.auth,
|
||||||
|
bezeichnung: geraetename(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gespeichert.ok) {
|
||||||
|
const fehler = (await gespeichert.json()) as { fehler?: string }
|
||||||
|
toast.error(fehler.fehler ?? 'Die Anmeldung ließ sich nicht speichern.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPushZustand('an')
|
||||||
|
toast.success('Dieses Gerät empfängt jetzt Benachrichtigungen.')
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error('Die Anmeldung hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
setPushLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushAbmelden() {
|
||||||
|
setPushLaeuft(true)
|
||||||
|
try {
|
||||||
|
const registrierung = await navigator.serviceWorker.getRegistration()
|
||||||
|
const abo = await registrierung?.pushManager.getSubscription()
|
||||||
|
if (abo) {
|
||||||
|
await fetch('/api/push', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ endpunkt: abo.endpoint }),
|
||||||
|
})
|
||||||
|
await abo.unsubscribe()
|
||||||
|
}
|
||||||
|
setPushZustand('aus')
|
||||||
|
toast.success('Dieses Gerät empfängt keine Benachrichtigungen mehr.')
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error('Das Abmelden hat nicht geklappt.')
|
||||||
|
} finally {
|
||||||
|
setPushLaeuft(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Dieses Gerät */}
|
||||||
|
<div className="border-rand flex flex-wrap items-center gap-3 rounded-md border p-3">
|
||||||
|
{pushZustand === 'an' ? (
|
||||||
|
<Bell className="text-erfolg size-5 shrink-0" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<BellOff className="text-gedaempft-vordergrund size-5 shrink-0" aria-hidden />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">Dieses Gerät</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
{pushZustand === 'nicht-unterstuetzt' &&
|
||||||
|
'Dieser Browser kann keine Push-Nachrichten empfangen.'}
|
||||||
|
{pushZustand === 'verweigert' &&
|
||||||
|
'Der Browser hat Benachrichtigungen blockiert. Das lässt sich nur in seinen Einstellungen ändern.'}
|
||||||
|
{pushZustand === 'an' && 'Empfängt Push-Nachrichten.'}
|
||||||
|
{pushZustand === 'aus' && 'Empfängt keine Push-Nachrichten.'}
|
||||||
|
{pushZustand === 'unbekannt' && '…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!pushMoeglich ? (
|
||||||
|
<Kennzeichen art="warnung">Auf dem Server nicht eingerichtet</Kennzeichen>
|
||||||
|
) : pushZustand === 'an' ? (
|
||||||
|
<Button art="umriss" disabled={pushLaeuft} onClick={() => void pushAbmelden()}>
|
||||||
|
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Abmelden
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
pushZustand !== 'nicht-unterstuetzt' &&
|
||||||
|
pushZustand !== 'verweigert' && (
|
||||||
|
<Button disabled={pushLaeuft} onClick={() => void pushAnmelden()}>
|
||||||
|
{pushLaeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Anmelden
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Einstellungen */}
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-5"
|
||||||
|
onSubmit={(ereignis) => {
|
||||||
|
ereignis.preventDefault()
|
||||||
|
const daten = new FormData(ereignis.currentTarget)
|
||||||
|
mitRueckmeldung(() => einstellungSpeichern(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium">Worüber</legend>
|
||||||
|
{ANLAESSE.map((anlass) => (
|
||||||
|
<label key={anlass} className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="anlaesse"
|
||||||
|
value={anlass}
|
||||||
|
defaultChecked={einstellung.anlaesse.includes(anlass)}
|
||||||
|
className="accent-betont mt-0.5 size-4"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="text-sm">{ANLASS_NAME[anlass]}</span>
|
||||||
|
<span className="text-gedaempft-vordergrund block text-xs">
|
||||||
|
{ANLASS_BESCHREIBUNG[anlass]}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset className="flex flex-col gap-3">
|
||||||
|
<legend className="mb-1 text-sm font-medium">Wie</legend>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perPush"
|
||||||
|
defaultChecked={einstellung.perPush}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
Push an angemeldete Geräte
|
||||||
|
{!pushMoeglich && (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="perMail"
|
||||||
|
defaultChecked={einstellung.perMail}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
E-Mail
|
||||||
|
{!mailMoeglich && (
|
||||||
|
<span className="text-gedaempft-vordergrund">
|
||||||
|
{' '}
|
||||||
|
— auf diesem Server nicht eingerichtet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ueberblickUm">Tagesüberblick um</Label>
|
||||||
|
<Input
|
||||||
|
id="ueberblickUm"
|
||||||
|
name="ueberblickUm"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ueberblickUm}
|
||||||
|
aria-invalid={Boolean(felder.ueberblickUm)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="zeitblockVorlaufMinuten">
|
||||||
|
Vorlauf vor einem Zeitblock (Minuten)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="zeitblockVorlaufMinuten"
|
||||||
|
name="zeitblockVorlaufMinuten"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
defaultValue={einstellung.zeitblockVorlaufMinuten}
|
||||||
|
aria-invalid={Boolean(felder.zeitblockVorlaufMinuten)}
|
||||||
|
/>
|
||||||
|
{felder.zeitblockVorlaufMinuten && (
|
||||||
|
<p className="text-gefahr text-xs">{felder.zeitblockVorlaufMinuten}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ruheVon">Ruhe von</Label>
|
||||||
|
<Input
|
||||||
|
id="ruheVon"
|
||||||
|
name="ruheVon"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ruheVon}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="ruheBis">bis</Label>
|
||||||
|
<Input
|
||||||
|
id="ruheBis"
|
||||||
|
name="ruheBis"
|
||||||
|
type="time"
|
||||||
|
defaultValue={einstellung.ruheBis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="ruheAmWochenende"
|
||||||
|
defaultChecked={einstellung.ruheAmWochenende}
|
||||||
|
className="accent-betont size-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Am Wochenende gar nichts melden</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
In der Ruhezeit bleibt alles still — bis auf den Tagesüberblick. Der käme sonst
|
||||||
|
nie, wenn du früh aufstehst.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="submit" disabled={laeuft}>
|
||||||
|
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
art="umriss"
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => mitRueckmeldung(() => probeSenden())}
|
||||||
|
>
|
||||||
|
<Send aria-hidden />
|
||||||
|
Probe schicken
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Angemeldete Geräte */}
|
||||||
|
{geraete.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h3 className="text-sm font-medium">Angemeldete Geräte</h3>
|
||||||
|
<ul className="divide-rand divide-y">
|
||||||
|
{geraete.map((geraet) => (
|
||||||
|
<li key={geraet.id} className="flex items-center gap-3 py-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm">{geraet.bezeichnung ?? 'Unbenanntes Gerät'}</p>
|
||||||
|
<p className="text-gedaempft-vordergrund text-xs">
|
||||||
|
Angemeldet {geraet.erstelltAm}
|
||||||
|
{geraet.zuletztBenutzt
|
||||||
|
? ` · zuletzt benachrichtigt ${geraet.zuletztBenutzt}`
|
||||||
|
: ' · noch nichts empfangen'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
art="leise"
|
||||||
|
groesse="quadrat"
|
||||||
|
title={`${geraet.bezeichnung ?? 'Gerät'} abmelden`}
|
||||||
|
disabled={laeuft}
|
||||||
|
onClick={() => {
|
||||||
|
const daten = new FormData()
|
||||||
|
daten.set('id', geraet.id)
|
||||||
|
mitRueckmeldung(() => geraetEntfernen(daten))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden />
|
||||||
|
<span className="sr-only">Abmelden</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grobe Kennung des Browsers, damit sich Geräte auseinanderhalten lassen. */
|
||||||
|
function geraetename(): string {
|
||||||
|
const kennung = navigator.userAgent
|
||||||
|
if (/Android/i.test(kennung)) return 'Android-Gerät'
|
||||||
|
if (/iPhone|iPad/i.test(kennung)) return 'iPhone oder iPad'
|
||||||
|
if (/Macintosh/i.test(kennung)) return 'Mac'
|
||||||
|
if (/Windows/i.test(kennung)) return 'Windows-Rechner'
|
||||||
|
if (/Linux/i.test(kennung)) return 'Linux-Rechner'
|
||||||
|
return 'Unbekanntes Gerät'
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { Benachrichtigungen } from '@/app/(app)/einstellungen/benachrichtigungen'
|
||||||
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -8,6 +9,9 @@ import {
|
|||||||
CardTitel,
|
CardTitel,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { ROLLEN_NAME } from '@/lib/rollen'
|
import { ROLLEN_NAME } from '@/lib/rollen'
|
||||||
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { alsUhrzeit } from '@/lib/zeit'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||||
import { db } from '@/server/db'
|
import { db } from '@/server/db'
|
||||||
@@ -17,6 +21,35 @@ export const metadata: Metadata = { title: 'Einstellungen' }
|
|||||||
export default async function EinstellungenSeite() {
|
export default async function EinstellungenSeite() {
|
||||||
const benutzer = await verlangeAnmeldung('/einstellungen')
|
const benutzer = await verlangeAnmeldung('/einstellungen')
|
||||||
|
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
const [benachrichtigung, pushGeraete] = await Promise.all([
|
||||||
|
db.benachrichtigungseinstellung.findUnique({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.pushGeraet.findMany({
|
||||||
|
where: { benutzerId: benutzer.id },
|
||||||
|
orderBy: { erstelltAm: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
bezeichnung: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
zuletztBenutzt: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const einstellung = alsEinstellung(benachrichtigung)
|
||||||
|
|
||||||
const passkeys = await db.passkey.findMany({
|
const passkeys = await db.passkey.findMany({
|
||||||
where: { benutzerId: benutzer.id },
|
where: { benutzerId: benutzer.id },
|
||||||
orderBy: { erstelltAm: 'asc' },
|
orderBy: { erstelltAm: 'asc' },
|
||||||
@@ -72,6 +105,39 @@ export default async function EinstellungenSeite() {
|
|||||||
/>
|
/>
|
||||||
</CardInhalt>
|
</CardInhalt>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card data-bild="benachrichtigungen">
|
||||||
|
<CardKopf>
|
||||||
|
<CardTitel className="text-base">Benachrichtigungen</CardTitel>
|
||||||
|
<CardBeschreibung>
|
||||||
|
Ab Werk meldet der Planer gar nichts. Was du hier einschaltest, kommt — alles
|
||||||
|
andere nicht. Eine Anwendung, die zu oft meldet, wird stummgeschaltet und ist
|
||||||
|
dann wertlos.
|
||||||
|
</CardBeschreibung>
|
||||||
|
</CardKopf>
|
||||||
|
<CardInhalt>
|
||||||
|
<Benachrichtigungen
|
||||||
|
pushMoeglich={einst.pushAktiv}
|
||||||
|
mailMoeglich={einst.mailAktiv}
|
||||||
|
einstellung={{
|
||||||
|
anlaesse: [...einstellung.anlaesse],
|
||||||
|
perPush: einstellung.perPush,
|
||||||
|
perMail: einstellung.perMail,
|
||||||
|
ueberblickUm: alsUhrzeit(einstellung.ueberblickUm),
|
||||||
|
ruheVon: alsUhrzeit(einstellung.ruheVon),
|
||||||
|
ruheBis: alsUhrzeit(einstellung.ruheBis),
|
||||||
|
ruheAmWochenende: einstellung.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: einstellung.zeitblockVorlaufMinuten,
|
||||||
|
}}
|
||||||
|
geraete={pushGeraete.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
bezeichnung: g.bezeichnung,
|
||||||
|
erstelltAm: alsDatumUhrzeit(g.erstelltAm),
|
||||||
|
zuletztBenutzt: g.zuletztBenutzt ? alsDatumUhrzeit(g.zuletztBenutzt) : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</CardInhalt>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
'use server'
|
||||||
|
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { einstellungSchema } from '@/lib/benachrichtigung'
|
||||||
|
import { alsTagesschluessel } from '@/lib/zeit'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
|
||||||
|
import { mailSenden } from '@/server/benachrichtigung/mail'
|
||||||
|
import { pushSenden } from '@/server/benachrichtigung/push'
|
||||||
|
import { alsEinstellung } from '@/server/benachrichtigung/versand'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { umgebung } from '@/server/umgebung'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export type Rueckmeldung =
|
||||||
|
| { ok: true; meldung: string }
|
||||||
|
| { ok: false; fehler: string; felder?: Record<string, string> }
|
||||||
|
|
||||||
|
async function akteurOderFehler() {
|
||||||
|
try {
|
||||||
|
return { akteur: await pruefeRolle('BENUTZER') }
|
||||||
|
} catch (fehler) {
|
||||||
|
if (fehler instanceof NichtErlaubt) {
|
||||||
|
return { antwort: { ok: false as const, fehler: fehler.message } }
|
||||||
|
}
|
||||||
|
throw fehler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert die Benachrichtigungseinstellung des angemeldeten Benutzers. */
|
||||||
|
export async function einstellungSpeichern(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const gelesen = einstellungSchema.safeParse({
|
||||||
|
anlaesse: daten.getAll('anlaesse'),
|
||||||
|
perPush: daten.get('perPush') !== null,
|
||||||
|
perMail: daten.get('perMail') !== null,
|
||||||
|
ueberblickUm: daten.get('ueberblickUm'),
|
||||||
|
ruheVon: daten.get('ruheVon'),
|
||||||
|
ruheBis: daten.get('ruheBis'),
|
||||||
|
ruheAmWochenende: daten.get('ruheAmWochenende') !== null,
|
||||||
|
zeitblockVorlaufMinuten: daten.get('zeitblockVorlaufMinuten'),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!gelesen.success) {
|
||||||
|
const felder: Record<string, string> = {}
|
||||||
|
for (const problem of gelesen.error.issues) {
|
||||||
|
const feld = problem.path.join('.')
|
||||||
|
felder[feld] ??= problem.message
|
||||||
|
}
|
||||||
|
return { ok: false, fehler: 'Bitte die markierten Felder prüfen.', felder }
|
||||||
|
}
|
||||||
|
|
||||||
|
const werte = {
|
||||||
|
anlaesse: gelesen.data.anlaesse.join(','),
|
||||||
|
perPush: gelesen.data.perPush,
|
||||||
|
perMail: gelesen.data.perMail,
|
||||||
|
ueberblickUm: gelesen.data.ueberblickUm,
|
||||||
|
ruheVon: gelesen.data.ruheVon,
|
||||||
|
ruheBis: gelesen.data.ruheBis,
|
||||||
|
ruheAmWochenende: gelesen.data.ruheAmWochenende,
|
||||||
|
zeitblockVorlaufMinuten: gelesen.data.zeitblockVorlaufMinuten,
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.benachrichtigungseinstellung.upsert({
|
||||||
|
where: { benutzerId: akteur.id },
|
||||||
|
update: werte,
|
||||||
|
create: { benutzerId: akteur.id, ...werte },
|
||||||
|
})
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'benachrichtigung.geaendert',
|
||||||
|
objektTyp: 'Benachrichtigungseinstellung',
|
||||||
|
objektId: akteur.id,
|
||||||
|
details: {
|
||||||
|
anlaesse: werte.anlaesse,
|
||||||
|
perPush: werte.perPush,
|
||||||
|
perMail: werte.perMail,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen')
|
||||||
|
return { ok: true, meldung: 'Einstellungen gespeichert.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schickt eine Probemeldung über alle eingeschalteten Wege.
|
||||||
|
*
|
||||||
|
* Sie umgeht Ruhezeit und Wiederholungssperre bewusst — wer auf den Knopf
|
||||||
|
* drückt, will jetzt wissen, ob es ankommt.
|
||||||
|
*/
|
||||||
|
export async function probeSenden(): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const zeile = await db.benachrichtigungseinstellung.findUnique({
|
||||||
|
where: { benutzerId: akteur.id },
|
||||||
|
select: {
|
||||||
|
anlaesse: true,
|
||||||
|
perPush: true,
|
||||||
|
perMail: true,
|
||||||
|
ueberblickUm: true,
|
||||||
|
ruheVon: true,
|
||||||
|
ruheBis: true,
|
||||||
|
ruheAmWochenende: true,
|
||||||
|
zeitblockVorlaufMinuten: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const einstellung = alsEinstellung(zeile)
|
||||||
|
|
||||||
|
if (!einstellung.perPush && !einstellung.perMail) {
|
||||||
|
return { ok: false, fehler: 'Erst einen Weg einschalten — Push oder E-Mail.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const meldung = {
|
||||||
|
anlass: 'tagesueberblick' as const,
|
||||||
|
// Die Probe umgeht Sperre und Ruhezeit; der Schlüssel dient nur der Form.
|
||||||
|
schluessel: `probe:${akteur.id}:${alsTagesschluessel(jetzt())}`,
|
||||||
|
titel: 'Probemeldung',
|
||||||
|
text: 'Wenn du das liest, kommen Benachrichtigungen an.',
|
||||||
|
pfad: '/einstellungen',
|
||||||
|
}
|
||||||
|
|
||||||
|
const wege: string[] = []
|
||||||
|
const einst = umgebung()
|
||||||
|
|
||||||
|
if (einstellung.perPush) {
|
||||||
|
if (!einst.pushAktiv) {
|
||||||
|
wege.push('Push ist auf diesem Server nicht eingerichtet')
|
||||||
|
} else {
|
||||||
|
const bericht = await pushSenden(akteur.id, meldung)
|
||||||
|
wege.push(
|
||||||
|
bericht.zugestellt > 0
|
||||||
|
? `Push an ${bericht.zugestellt} Gerät(e)`
|
||||||
|
: 'kein Gerät für Push angemeldet',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (einstellung.perMail) {
|
||||||
|
if (!einst.mailAktiv) {
|
||||||
|
wege.push('E-Mail ist auf diesem Server nicht eingerichtet')
|
||||||
|
} else {
|
||||||
|
wege.push(
|
||||||
|
(await mailSenden(akteur.email, meldung))
|
||||||
|
? 'E-Mail verschickt'
|
||||||
|
: 'E-Mail fehlgeschlagen',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'benachrichtigung.probe',
|
||||||
|
objektTyp: 'Benachrichtigungseinstellung',
|
||||||
|
objektId: akteur.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ok: true, meldung: wege.join(' · ') }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt ein angemeldetes Push-Gerät. */
|
||||||
|
export async function geraetEntfernen(daten: FormData): Promise<Rueckmeldung> {
|
||||||
|
const geprueft = await akteurOderFehler()
|
||||||
|
if ('antwort' in geprueft) return geprueft.antwort
|
||||||
|
const { akteur } = geprueft
|
||||||
|
|
||||||
|
const id = daten.get('id')
|
||||||
|
if (typeof id !== 'string' || id.length === 0) {
|
||||||
|
return { ok: false, fehler: 'Ungültige Anfrage.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const geraet = await db.pushGeraet.findFirst({
|
||||||
|
where: { id, benutzerId: akteur.id },
|
||||||
|
select: { id: true, bezeichnung: true },
|
||||||
|
})
|
||||||
|
if (!geraet) return { ok: false, fehler: 'Dieses Gerät gibt es nicht.' }
|
||||||
|
|
||||||
|
await db.pushGeraet.delete({ where: { id: geraet.id } })
|
||||||
|
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: akteur.id, email: akteur.email },
|
||||||
|
aktion: 'push.abgemeldet',
|
||||||
|
objektTyp: 'PushGeraet',
|
||||||
|
objektId: geraet.id,
|
||||||
|
details: { bezeichnung: geraet.bezeichnung },
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath('/einstellungen')
|
||||||
|
return { ok: true, meldung: 'Gerät abgemeldet.' }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user