feat(kalender): Seite für die Kalenderkonten

Verbinden, umbenennen, von Hand abgleichen und trennen. Alle Aktionen
arbeiten nur auf Konten des angemeldeten Benutzers — die Kennung geht in
die where-Bedingung, nicht in einen nachträglichen Vergleich.

Ein Anbieter erscheint nur, wenn die Instanz ihn erlaubt *und* er
konfiguriert ist. Ist er erlaubt, aber nicht konfiguriert, sagt die Seite
das ausdrücklich, statt den Knopf wortlos wegzulassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 18:05:07 +02:00
co-authored by Claude Opus 5
parent 9946e7c308
commit 0c67b73a62
5 changed files with 449 additions and 0 deletions
+126
View File
@@ -0,0 +1,126 @@
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { auditSchreiben } from '@/server/audit'
import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung'
import { db } from '@/server/db'
import { kontoAbgleichen } from '@/server/kalender/abgleich'
export type Rueckmeldung = { ok: true; meldung: string } | { ok: false; fehler: string }
const eingabe = z.object({ id: z.string().min(1) })
/**
* Alle Aktionen arbeiten nur auf Konten des angemeldeten Benutzers — die
* Kennung geht in die where-Bedingung, nicht in einen nachträglichen
* Vergleich.
*/
async function eigenesKonto(id: string, benutzerId: string) {
return db.kalenderkonto.findFirst({
where: { id, benutzerId },
select: { id: true, anzeigename: true, anbieter: true },
})
}
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
}
}
export async function jetztAbgleichen(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const gelesen = eingabe.safeParse({ id: daten.get('id') })
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id)
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
const bericht = await kontoAbgleichen(konto.id)
revalidatePath('/kalenderkonten')
if (!bericht.ok) {
return { ok: false, fehler: bericht.fehler ?? 'Der Abgleich schlug fehl.' }
}
return {
ok: true,
meldung: `„${konto.anzeigename}": ${bericht.geaendert} Termin(e) gelesen, ${bericht.entfernt} entfernt.`,
}
}
const umbenennen = z.object({
id: z.string().min(1),
anzeigename: z.string().trim().min(2, 'Mindestens zwei Zeichen.').max(60),
})
export async function kontoUmbenennen(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const gelesen = umbenennen.safeParse({
id: daten.get('id'),
anzeigename: daten.get('anzeigename'),
})
if (!gelesen.success) {
return { ok: false, fehler: 'Bitte einen Namen mit mindestens zwei Zeichen angeben.' }
}
const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id)
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
await db.kalenderkonto.update({
where: { id: konto.id },
data: { anzeigename: gelesen.data.anzeigename },
})
await auditSchreiben({
akteur: { id: geprueft.akteur.id, email: geprueft.akteur.email },
aktion: 'kalenderkonto.umbenannt',
objektTyp: 'Kalenderkonto',
objektId: konto.id,
details: { vorher: konto.anzeigename, nachher: gelesen.data.anzeigename },
})
revalidatePath('/kalenderkonten')
return { ok: true, meldung: 'Name gespeichert.' }
}
/**
* Trennt ein Kalenderkonto. Die gespiegelten Termine verschwinden mit ihm —
* sie stammen aus dem Kalender und sind dort weiterhin vorhanden.
*/
export async function kontoTrennen(daten: FormData): Promise<Rueckmeldung> {
const geprueft = await akteurOderFehler()
if ('antwort' in geprueft) return geprueft.antwort
const gelesen = eingabe.safeParse({ id: daten.get('id') })
if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' }
const konto = await eigenesKonto(gelesen.data.id, geprueft.akteur.id)
if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' }
await db.kalenderkonto.delete({ where: { id: konto.id } })
await auditSchreiben({
akteur: { id: geprueft.akteur.id, email: geprueft.akteur.email },
aktion: 'kalenderkonto.getrennt',
objektTyp: 'Kalenderkonto',
objektId: konto.id,
details: { anbieter: konto.anbieter, anzeigename: konto.anzeigename },
})
revalidatePath('/kalenderkonten')
revalidatePath('/')
return {
ok: true,
meldung: `„${konto.anzeigename}" getrennt. Die Termine bleiben im Kalender selbst erhalten.`,
}
}