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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5791047982
commit
9c1c916b58
@@ -0,0 +1,352 @@
|
||||
'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}` }
|
||||
}
|
||||
Reference in New Issue
Block a user