Files
planer/src/server/aktionen/zeitbloecke.ts
T
Florian WildeandClaude Opus 5 9c1c916b58 feat(zeitbloecke): Schreibweg in den Kalender und Rückweg zurück
Anlegen, verschieben und lösen schreiben in den Kalender des Anbieters.
Jeder Zugriff auf einen bestehenden Termin geht mit If-Match und dem
zuletzt gelesenen Etag heraus; antwortet der Anbieter mit 412, gewinnt der
Kalender: der Planer schreibt nicht, verwirft seinen Fingerabdruck und
überlässt dem nächsten Abgleich, den fremden Stand zu holen.

Der Rückweg läuft nach jedem Abgleich und vergleicht die eigenen
Zeitblöcke mit dem aufgefrischten Spiegel. Wurde im Kalender verschoben,
zieht der Planer nach. Wurde dort gelöscht, verliert der Zeitblock seine
Verankerung und die Aufgabe fällt zurück auf offen, wenn nichts anderes
mehr für sie geplant ist.

Zwei Entscheidungen zum Fehlerfall:
- Scheitert das Schreiben beim Anlegen, bleibt der Zeitblock trotzdem
  bestehen, mit vermerktem Fehler. Ihn zu verwerfen hieße, die Planung
  wegen eines Netzproblems zu vergessen; das Verschieben trägt ihn nach.
- Scheitert das Entfernen, verschwindet der Zeitblock dennoch. Bliebe er
  stehen, weil der Kalender gerade nicht erreichbar ist, wäre die Planung
  blockiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 20:49:52 +02:00

353 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<Rueckmeldung> {
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<Rueckmeldung> {
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<Rueckmeldung> {
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}` }
}