'use server' import { revalidatePath } from 'next/cache' import { z } from 'zod' import { zeitblockSchema, zeitraumHash, LAGE_FEHLER_TEXT, lagePruefen, } from '@/lib/zeitblock' import { auditSchreiben } from '@/server/audit' import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' import { db } from '@/server/db' import { gueltigesTokenFuer } from '@/server/kalender/abgleich' import { KalenderGewinnt, terminAendern, terminAnlegen, terminEntfernen, } from '@/server/kalender/schreiben' import { jetzt } from '@/server/zeitgeber' export type Rueckmeldung = { ok: true; meldung: string; id?: string } | { ok: false; fehler: 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 } } /** * Lädt einen Zeitblock, aber nur, wenn er dem angemeldeten Benutzer gehört — * die Kennung geht über das Kalenderkonto in die where-Bedingung. */ async function eigenerZeitblock(id: string, benutzerId: string) { return db.zeitblock.findFirst({ where: { id, kalenderkonto: { benutzerId } }, select: { id: true, beginn: true, ende: true, extId: true, etag: true, hash: true, aufgabe: { select: { id: true, titel: true, notiz: true, status: true } }, kalenderkonto: { select: { id: true, anbieter: true, kalenderId: true, anzeigename: true }, }, }, }) } /** Was im Kalender als Betreff steht. */ function betreffFuer(titel: string): string { return titel } // --- Anlegen --------------------------------------------------------------- /** * Legt einen Zeitblock an und schreibt ihn in den Kalender. * * Klappt das Schreiben nicht, bleibt der Zeitblock trotzdem bestehen — mit * vermerktem Fehler. Ihn wieder zu verwerfen hieße, die Planung des Benutzers * wegen eines Netzproblems zu vergessen; der nächste Versuch kann ihn * nachtragen. */ export async function zeitblockAnlegen(daten: FormData): Promise { const geprueft = await akteurOderFehler() if ('antwort' in geprueft) return geprueft.antwort const { akteur } = geprueft const gelesen = zeitblockSchema.safeParse({ aufgabeId: daten.get('aufgabeId'), kalenderkontoId: daten.get('kalenderkontoId'), beginn: daten.get('beginn'), ende: daten.get('ende'), }) if (!gelesen.success) { const lage = gelesen.error.issues.find((i) => i.path.includes('ende')) return { ok: false, fehler: lage?.message ?? 'Die Angaben stimmen nicht.' } } const [aufgabe, konto] = await Promise.all([ db.aufgabe.findUnique({ where: { id: gelesen.data.aufgabeId }, select: { id: true, titel: true, notiz: true, status: true }, }), db.kalenderkonto.findFirst({ where: { id: gelesen.data.kalenderkontoId, benutzerId: akteur.id, aktiv: true }, select: { id: true, anbieter: true, kalenderId: true, anzeigename: true }, }), ]) if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht.' } if (!konto) return { ok: false, fehler: 'Dieses Kalenderkonto gibt es nicht.' } const zeitblock = await db.zeitblock.create({ data: { aufgabeId: aufgabe.id, kalenderkontoId: konto.id, beginn: gelesen.data.beginn, ende: gelesen.data.ende, }, select: { id: true }, }) // Eine eingeplante Aufgabe ist geplant, nicht mehr bloß offen. if (aufgabe.status === 'OFFEN') { await db.aufgabe.update({ where: { id: aufgabe.id }, data: { status: 'GEPLANT' } }) } let schreibfehler: string | null = null try { const token = await gueltigesTokenFuer(konto.id, jetzt()) const ergebnis = await terminAnlegen({ anbieter: konto.anbieter, kalenderId: konto.kalenderId, token, betreff: betreffFuer(aufgabe.titel), beschreibung: aufgabe.notiz, beginn: gelesen.data.beginn, ende: gelesen.data.ende, }) await db.zeitblock.update({ where: { id: zeitblock.id }, data: { extId: ergebnis.extId, etag: ergebnis.etag, hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), letzterFehler: null, }, }) } catch (fehler) { schreibfehler = fehler instanceof Error ? fehler.message : 'Der Termin ließ sich nicht anlegen.' await db.zeitblock.update({ where: { id: zeitblock.id }, data: { letzterFehler: schreibfehler }, }) } await auditSchreiben({ akteur: { id: akteur.id, email: akteur.email }, aktion: 'zeitblock.angelegt', objektTyp: 'Zeitblock', objektId: zeitblock.id, details: { aufgabe: aufgabe.titel, kalender: konto.anzeigename, geschrieben: schreibfehler === null, }, }) revalidatePath('/woche') revalidatePath('/aufgaben') revalidatePath('/') return schreibfehler ? { ok: false, fehler: `Eingeplant, aber nicht in den Kalender geschrieben: ${schreibfehler}`, } : { ok: true, id: zeitblock.id, meldung: `„${aufgabe.titel}" eingeplant.` } } // --- Verschieben ----------------------------------------------------------- const verschieben = z.object({ id: z.string().min(1), beginn: z.coerce.date(), ende: z.coerce.date(), }) /** * Verschiebt einen Zeitblock und zieht den Kalender nach. * * Antwortet der Anbieter mit 412, hat jemand anderes den Termin inzwischen * angefasst. Dann gewinnt der Kalender: der Planer schreibt nicht, sondern * meldet das und überlässt dem nächsten Abgleich, den fremden Stand zu holen. */ export async function zeitblockVerschieben(daten: FormData): Promise { const geprueft = await akteurOderFehler() if ('antwort' in geprueft) return geprueft.antwort const { akteur } = geprueft const gelesen = verschieben.safeParse({ id: daten.get('id'), beginn: daten.get('beginn'), ende: daten.get('ende'), }) if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } const lage = lagePruefen(gelesen.data.beginn, gelesen.data.ende) if (lage) return { ok: false, fehler: LAGE_FEHLER_TEXT[lage] } const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id) if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' } await db.zeitblock.update({ where: { id: zeitblock.id }, data: { beginn: gelesen.data.beginn, ende: gelesen.data.ende }, }) let meldung = `„${zeitblock.aufgabe.titel}" verschoben.` let ok = true try { const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt()) if (zeitblock.extId) { const ergebnis = await terminAendern({ anbieter: zeitblock.kalenderkonto.anbieter, kalenderId: zeitblock.kalenderkonto.kalenderId, token, extId: zeitblock.extId, etag: zeitblock.etag, betreff: betreffFuer(zeitblock.aufgabe.titel), beschreibung: zeitblock.aufgabe.notiz, beginn: gelesen.data.beginn, ende: gelesen.data.ende, }) await db.zeitblock.update({ where: { id: zeitblock.id }, data: { etag: ergebnis.etag, hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), letzterFehler: null, }, }) } else { // Beim Anlegen war der Kalender nicht erreichbar – jetzt nachtragen. const ergebnis = await terminAnlegen({ anbieter: zeitblock.kalenderkonto.anbieter, kalenderId: zeitblock.kalenderkonto.kalenderId, token, betreff: betreffFuer(zeitblock.aufgabe.titel), beschreibung: zeitblock.aufgabe.notiz, beginn: gelesen.data.beginn, ende: gelesen.data.ende, }) await db.zeitblock.update({ where: { id: zeitblock.id }, data: { extId: ergebnis.extId, etag: ergebnis.etag, hash: zeitraumHash(gelesen.data.beginn, gelesen.data.ende), letzterFehler: null, }, }) } } catch (fehler) { ok = false if (fehler instanceof KalenderGewinnt) { meldung = 'Der Termin wurde inzwischen im Kalender geändert. Der Kalender gewinnt — der nächste Abgleich holt den dortigen Stand.' // Den eigenen Fingerabdruck verwerfen, damit der Abgleich die fremde // Änderung als solche erkennt. await db.zeitblock.update({ where: { id: zeitblock.id }, data: { hash: null, letzterFehler: meldung }, }) } else { meldung = fehler instanceof Error ? fehler.message : 'Der Kalender ließ sich nicht ändern.' await db.zeitblock.update({ where: { id: zeitblock.id }, data: { letzterFehler: meldung }, }) } } await auditSchreiben({ akteur: { id: akteur.id, email: akteur.email }, aktion: 'zeitblock.verschoben', objektTyp: 'Zeitblock', objektId: zeitblock.id, details: { aufgabe: zeitblock.aufgabe.titel, geschrieben: ok }, }) revalidatePath('/woche') revalidatePath('/') return ok ? { ok: true, meldung } : { ok: false, fehler: meldung } } // --- Lösen ----------------------------------------------------------------- const loesen = z.object({ id: z.string().min(1) }) /** Nimmt einen Zeitblock zurück und entfernt den Termin aus dem Kalender. */ export async function zeitblockLoesen(daten: FormData): Promise { const geprueft = await akteurOderFehler() if ('antwort' in geprueft) return geprueft.antwort const { akteur } = geprueft const gelesen = loesen.safeParse({ id: daten.get('id') }) if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } const zeitblock = await eigenerZeitblock(gelesen.data.id, akteur.id) if (!zeitblock) return { ok: false, fehler: 'Diesen Zeitblock gibt es nicht mehr.' } let hinweis = '' if (zeitblock.extId) { try { const token = await gueltigesTokenFuer(zeitblock.kalenderkonto.id, jetzt()) await terminEntfernen({ anbieter: zeitblock.kalenderkonto.anbieter, kalenderId: zeitblock.kalenderkonto.kalenderId, token, extId: zeitblock.extId, etag: zeitblock.etag, }) } catch { // Der Zeitblock verschwindet trotzdem. Bliebe er stehen, weil der // Kalender gerade nicht erreichbar ist, wäre die Planung blockiert. hinweis = ' Der Termin im Kalender ließ sich nicht entfernen.' } } await db.zeitblock.delete({ where: { id: zeitblock.id } }) // Bleibt keine Planung übrig, ist die Aufgabe wieder schlicht offen. const nochGeplant = await db.zeitblock.count({ where: { aufgabeId: zeitblock.aufgabe.id }, }) if (nochGeplant === 0 && zeitblock.aufgabe.status === 'GEPLANT') { await db.aufgabe.update({ where: { id: zeitblock.aufgabe.id }, data: { status: 'OFFEN' }, }) } await auditSchreiben({ akteur: { id: akteur.id, email: akteur.email }, aktion: 'zeitblock.geloest', objektTyp: 'Zeitblock', objektId: zeitblock.id, details: { aufgabe: zeitblock.aufgabe.titel }, }) revalidatePath('/woche') revalidatePath('/aufgaben') revalidatePath('/') return { ok: true, meldung: `„${zeitblock.aufgabe.titel}" ausgeplant.${hinweis}` } }