diff --git a/src/server/abfragen/aufgaben.ts b/src/server/abfragen/aufgaben.ts new file mode 100644 index 0000000..5aeb6fb --- /dev/null +++ b/src/server/abfragen/aufgaben.ts @@ -0,0 +1,144 @@ +import 'server-only' +import type { Prisma } from '@/generated/prisma/client' +import type { AufgabenStatus, AufgabenTyp } from '@/generated/prisma/enums' +import { nachDringlichkeit } from '@/lib/aufgabe' +import { fuerAnzeige, type AufgabeAnzeige } from '@/lib/aufgabe-anzeige' +import { tagesBeginn } from '@/lib/zeit' +import { db } from '@/server/db' +import { jetzt } from '@/server/zeitgeber' + +/** + * Abfragen rund um Aufgaben. + * + * Alle Seiten laden über diese Stelle, damit Auswahl, Sortierung und + * Aufbereitung überall gleich sind. + */ + +const AUSWAHL = { + id: true, + titel: true, + notiz: true, + typ: true, + status: true, + prioritaet: true, + aufwandMinuten: true, + faelligAm: true, + quelle: true, + quelleUrl: true, + bereich: { select: { id: true, name: true, farbe: true } }, + projekt: { select: { id: true, name: true } }, +} satisfies Prisma.AufgabeSelect + +export type AufgabenFilter = { + bereichId?: string | undefined + projektId?: string | undefined + status?: AufgabenStatus | undefined + typ?: AufgabenTyp | undefined + /** Nur Aufgaben ohne Bereich (Inbox). */ + nurInbox?: boolean | undefined + /** Nur was noch Arbeit bedeutet. */ + nurOffen?: boolean | undefined + /** Freitext über Titel und Notiz. */ + suche?: string | undefined +} + +function alsWhere(filter: AufgabenFilter): Prisma.AufgabeWhereInput { + const where: Prisma.AufgabeWhereInput = {} + + if (filter.nurInbox) where.bereichId = null + else if (filter.bereichId) where.bereichId = filter.bereichId + + if (filter.projektId) where.projektId = filter.projektId + if (filter.typ) where.typ = filter.typ + + if (filter.status) where.status = filter.status + else if (filter.nurOffen) where.status = { in: ['OFFEN', 'GEPLANT', 'WARTET'] } + + if (filter.suche) { + where.OR = [ + { titel: { contains: filter.suche, mode: 'insensitive' } }, + { notiz: { contains: filter.suche, mode: 'insensitive' } }, + ] + } + + return where +} + +/** + * Lädt Aufgaben und bringt sie in die Reihenfolge der Dringlichkeit. + * + * Sortiert wird bewusst in der Anwendung, nicht in SQL: die Regel ist + * mehrstufig (offen vor abgeschlossen, dann Termin, dann Priorität, dann + * Titel mit deutscher Sortierung) und steht als geprüfte reine Funktion in + * src/lib. Zwei Fassungen derselben Regel wären eine Fehlerquelle. + */ +export async function aufgabenLaden( + filter: AufgabenFilter = {}, + grenze = 500, +): Promise { + const zeilen = await db.aufgabe.findMany({ + where: alsWhere(filter), + select: AUSWAHL, + orderBy: [{ faelligAm: 'asc' }, { prioritaet: 'asc' }], + take: grenze, + }) + + const zeitpunkt = jetzt() + return zeilen + .slice() + .sort(nachDringlichkeit) + .map((zeile) => fuerAnzeige(zeile, zeitpunkt)) +} + +/** Bereiche zur Auswahl in Formularen und Filtern. */ +export async function bereicheLaden() { + return db.bereich.findMany({ + where: { aktiv: true }, + orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }], + select: { id: true, name: true, farbe: true }, + }) +} + +/** Projekte zur Auswahl; abgeschlossene bleiben draußen. */ +export async function projekteZurAuswahlLaden() { + return db.projekt.findMany({ + where: { status: { in: ['AKTIV', 'PAUSIERT'] } }, + orderBy: { name: 'asc' }, + select: { id: true, name: true }, + }) +} + +/** Zählstände für die Navigation und die Startseite. */ +export async function zaehlstaende(): Promise<{ + inbox: number + offen: number + ueberfaellig: number + ideen: number +}> { + const zeitpunkt = jetzt() + const heuteBeginn = tagesBeginn(zeitpunkt) + + const [inbox, offen, ueberfaellig, ideen] = await Promise.all([ + db.aufgabe.count({ + where: { + bereichId: null, + typ: { not: 'IDEE' }, + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + }, + }), + db.aufgabe.count({ + where: { typ: { not: 'IDEE' }, status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } }, + }), + db.aufgabe.count({ + where: { + status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] }, + faelligAm: { lt: heuteBeginn }, + }, + }), + db.aufgabe.count({ + where: { typ: 'IDEE', status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } }, + }), + ]) + + return { inbox, offen, ueberfaellig, ideen } +} diff --git a/src/server/aktionen/aufgaben.ts b/src/server/aktionen/aufgaben.ts new file mode 100644 index 0000000..2a8af5a --- /dev/null +++ b/src/server/aktionen/aufgaben.ts @@ -0,0 +1,422 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import type { AufgabenStatus } from '@/generated/prisma/enums' +import { + aufgabeSchema, + istAbgeschlossen, + schnellerfassungSchema, + uebergangErlaubt, +} from '@/lib/aufgabe' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' +import { jetzt } from '@/server/zeitgeber' + +/** + * Server Actions für Aufgaben. + * + * Jede prüft die Rolle selbst — die Seite tut es ebenfalls, aber eine Action + * ist ein eigener Einstiegspunkt. Jede Änderung hinterlässt einen + * Audit-Eintrag. + */ + +export type Rueckmeldung = + | { ok: true; meldung: string; id?: string } + | { ok: false; fehler: string; felder?: Record } + +function feldFehler(fehler: z.ZodError): Record { + const felder: Record = {} + for (const problem of fehler.issues) { + const feld = problem.path.join('.') + felder[feld] ??= problem.message + } + return felder +} + +/** Prüft die Rolle und gibt bei Ablehnung eine Rückmeldung statt zu werfen. */ +async function akteurOder( + rueckgabe: (fehler: string) => Rueckmeldung, +): Promise< + { akteur: Awaited> } | { antwort: Rueckmeldung } +> { + try { + return { akteur: await pruefeRolle('BENUTZER') } + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { antwort: rueckgabe(fehler.message) } + throw fehler + } +} + +function alsFormularwerte(daten: FormData): Record { + return Object.fromEntries(daten.entries()) +} + +/** + * Prüft, dass angegebener Bereich und Projekt wirklich existieren. Ein + * erfundener Fremdschlüssel soll eine Meldung geben, keinen Datenbankfehler. + */ +async function verweiseGueltig( + bereichId: string | null, + projektId: string | null, +): Promise | null> { + const felder: Record = {} + + if (bereichId) { + const bereich = await db.bereich.findUnique({ + where: { id: bereichId }, + select: { id: true }, + }) + if (!bereich) felder.bereichId = 'Diesen Bereich gibt es nicht.' + } + if (projektId) { + const projekt = await db.projekt.findUnique({ + where: { id: projektId }, + select: { id: true }, + }) + if (!projekt) felder.projektId = 'Dieses Projekt gibt es nicht.' + } + + return Object.keys(felder).length > 0 ? felder : null +} + +// --- Schnellerfassung ------------------------------------------------------ + +/** + * Erfasst mit einem einzigen Feld. Ohne Bereich landet die Aufgabe in der + * Inbox und wird später einsortiert. + */ +export async function schnellErfassen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = schnellerfassungSchema.safeParse({ + titel: daten.get('titel'), + typ: daten.get('typ') ?? 'AUFGABE', + }) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte einen Titel angeben.', + felder: feldFehler(gelesen.error), + } + } + + const aufgabe = await db.aufgabe.create({ + data: { titel: gelesen.data.titel, typ: gelesen.data.typ }, + select: { id: true, titel: true, typ: true }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.angelegt', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { titel: aufgabe.titel, typ: aufgabe.typ, weg: 'schnellerfassung' }, + }) + + revalidatePath('/inbox') + revalidatePath('/aufgaben') + revalidatePath('/ideen') + return { + ok: true, + id: aufgabe.id, + meldung: + gelesen.data.typ === 'IDEE' ? 'Idee notiert.' : 'Aufgabe in der Inbox abgelegt.', + } +} + +// --- Anlegen und Ändern ---------------------------------------------------- + +export async function aufgabeAnlegen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = aufgabeSchema.safeParse(alsFormularwerte(daten)) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const verweisFehler = await verweiseGueltig( + gelesen.data.bereichId, + gelesen.data.projektId, + ) + if (verweisFehler) { + return { ok: false, fehler: 'Bitte die Zuordnung prüfen.', felder: verweisFehler } + } + + const aufgabe = await db.aufgabe.create({ + data: { + ...gelesen.data, + erledigtAm: istAbgeschlossen(gelesen.data.status) ? jetzt() : null, + }, + select: { id: true, titel: true }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.angelegt', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { titel: aufgabe.titel, typ: gelesen.data.typ, status: gelesen.data.status }, + }) + + revalidatePath('/aufgaben') + revalidatePath('/inbox') + revalidatePath('/ideen') + revalidatePath('/projekte') + return { ok: true, id: aufgabe.id, meldung: `„${aufgabe.titel}" angelegt.` } +} + +export async function aufgabeAendern(id: string, daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = aufgabeSchema.safeParse(alsFormularwerte(daten)) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const vorher = await db.aufgabe.findUnique({ + where: { id }, + select: { id: true, titel: true, status: true, erledigtAm: true }, + }) + if (!vorher) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' } + + if (!uebergangErlaubt(vorher.status, gelesen.data.status)) { + return { + ok: false, + fehler: + 'Dieser Statuswechsel ist nicht vorgesehen. Abgeschlossenes wird zuerst wieder geöffnet.', + felder: { status: 'Nicht möglich von hier aus.' }, + } + } + + const verweisFehler = await verweiseGueltig( + gelesen.data.bereichId, + gelesen.data.projektId, + ) + if (verweisFehler) { + return { ok: false, fehler: 'Bitte die Zuordnung prüfen.', felder: verweisFehler } + } + + const wirdAbgeschlossen = + istAbgeschlossen(gelesen.data.status) && !istAbgeschlossen(vorher.status) + const wirdGeoeffnet = + !istAbgeschlossen(gelesen.data.status) && istAbgeschlossen(vorher.status) + + await db.aufgabe.update({ + where: { id }, + data: { + ...gelesen.data, + ...(wirdAbgeschlossen ? { erledigtAm: jetzt() } : {}), + ...(wirdGeoeffnet ? { erledigtAm: null } : {}), + }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.geaendert', + objektTyp: 'Aufgabe', + objektId: id, + details: { + titel: gelesen.data.titel, + statusVorher: vorher.status, + statusNachher: gelesen.data.status, + }, + }) + + revalidatePath('/aufgaben') + revalidatePath('/inbox') + revalidatePath('/ideen') + revalidatePath('/projekte') + return { ok: true, id, meldung: `„${gelesen.data.titel}" gespeichert.` } +} + +// --- Kleine Handgriffe ----------------------------------------------------- + +const statusEingabe = z.object({ + id: z.string().min(1), + status: z.enum(['OFFEN', 'GEPLANT', 'WARTET', 'ERLEDIGT', 'VERWORFEN']), +}) + +/** Statuswechsel mit einem Klick, etwa das Abhaken in der Liste. */ +export async function statusSetzen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = statusEingabe.safeParse({ + id: daten.get('id'), + status: daten.get('status'), + }) + if (!gelesen.success) return { ok: false, fehler: 'Ungültige Anfrage.' } + + const vorher = await db.aufgabe.findUnique({ + where: { id: gelesen.data.id }, + select: { id: true, titel: true, status: true }, + }) + if (!vorher) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' } + + const neu: AufgabenStatus = gelesen.data.status + if (!uebergangErlaubt(vorher.status, neu)) { + return { ok: false, fehler: 'Dieser Statuswechsel ist nicht vorgesehen.' } + } + + await db.aufgabe.update({ + where: { id: vorher.id }, + data: { + status: neu, + erledigtAm: istAbgeschlossen(neu) ? jetzt() : null, + }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.status', + objektTyp: 'Aufgabe', + objektId: vorher.id, + details: { titel: vorher.titel, vorher: vorher.status, nachher: neu }, + }) + + revalidatePath('/aufgaben') + revalidatePath('/inbox') + revalidatePath('/ideen') + revalidatePath('/projekte') + return { ok: true, meldung: `„${vorher.titel}" ist jetzt ${neu.toLowerCase()}.` } +} + +const einsortierenEingabe = z.object({ + id: z.string().min(1), + bereichId: z.string().min(1), +}) + +/** Aus der Inbox in einen Bereich schieben. */ +export async function einsortieren(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + if ('antwort' in geprueft) return geprueft.antwort + const { akteur } = geprueft + + const gelesen = einsortierenEingabe.safeParse({ + id: daten.get('id'), + bereichId: daten.get('bereichId'), + }) + if (!gelesen.success) return { ok: false, fehler: 'Bitte einen Bereich wählen.' } + + const [aufgabe, bereich] = await Promise.all([ + db.aufgabe.findUnique({ + where: { id: gelesen.data.id }, + select: { id: true, titel: true }, + }), + db.bereich.findUnique({ + where: { id: gelesen.data.bereichId }, + select: { id: true, name: true }, + }), + ]) + + if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' } + if (!bereich) return { ok: false, fehler: 'Diesen Bereich gibt es nicht.' } + + await db.aufgabe.update({ + where: { id: aufgabe.id }, + data: { bereichId: bereich.id }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.einsortiert', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { titel: aufgabe.titel, bereich: bereich.name }, + }) + + revalidatePath('/inbox') + revalidatePath('/aufgaben') + return { ok: true, meldung: `„${aufgabe.titel}" liegt jetzt in ${bereich.name}.` } +} + +/** Aus einer Idee wird eine Aufgabe. */ +export async function ideeUebernehmen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + 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 idee = await db.aufgabe.findUnique({ + where: { id }, + select: { id: true, titel: true, typ: true }, + }) + if (!idee) return { ok: false, fehler: 'Diese Idee gibt es nicht mehr.' } + if (idee.typ !== 'IDEE') { + return { ok: false, fehler: 'Das ist keine Idee.' } + } + + await db.aufgabe.update({ + where: { id: idee.id }, + data: { typ: 'AUFGABE', status: 'OFFEN', erledigtAm: null }, + }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.aus-idee', + objektTyp: 'Aufgabe', + objektId: idee.id, + details: { titel: idee.titel }, + }) + + revalidatePath('/ideen') + revalidatePath('/aufgaben') + revalidatePath('/inbox') + return { ok: true, meldung: `„${idee.titel}" ist jetzt eine Aufgabe.` } +} + +// --- Löschen --------------------------------------------------------------- + +export async function aufgabeLoeschen(daten: FormData): Promise { + const geprueft = await akteurOder((fehler) => ({ ok: false, fehler })) + 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 aufgabe = await db.aufgabe.findUnique({ + where: { id }, + select: { id: true, titel: true, typ: true }, + }) + if (!aufgabe) return { ok: false, fehler: 'Diese Aufgabe gibt es nicht mehr.' } + + await db.aufgabe.delete({ where: { id: aufgabe.id } }) + + await auditSchreiben({ + akteur: { id: akteur.id, email: akteur.email }, + aktion: 'aufgabe.geloescht', + objektTyp: 'Aufgabe', + objektId: aufgabe.id, + details: { titel: aufgabe.titel, typ: aufgabe.typ }, + }) + + revalidatePath('/aufgaben') + revalidatePath('/inbox') + revalidatePath('/ideen') + revalidatePath('/projekte') + return { ok: true, meldung: `„${aufgabe.titel}" gelöscht.` } +} diff --git a/src/server/aktionen/projekte.ts b/src/server/aktionen/projekte.ts new file mode 100644 index 0000000..bb66790 --- /dev/null +++ b/src/server/aktionen/projekte.ts @@ -0,0 +1,173 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import type { z } from 'zod' +import { projektSchema } from '@/lib/aufgabe' +import { auditSchreiben } from '@/server/audit' +import { NichtErlaubt, pruefeRolle } from '@/server/berechtigung' +import { db } from '@/server/db' + +export type Rueckmeldung = + | { ok: true; meldung: string; id?: string } + | { ok: false; fehler: string; felder?: Record } + +function feldFehler(fehler: z.ZodError): Record { + const felder: Record = {} + for (const problem of fehler.issues) { + const feld = problem.path.join('.') + felder[feld] ??= problem.message + } + return felder +} + +async function akteur() { + return pruefeRolle('BENUTZER') +} + +export async function projektAnlegen(daten: FormData): Promise { + let ich + try { + ich = await akteur() + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const gelesen = projektSchema.safeParse(Object.fromEntries(daten.entries())) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const vorhanden = await db.projekt.findUnique({ + where: { name: gelesen.data.name }, + select: { id: true }, + }) + if (vorhanden) { + return { + ok: false, + fehler: 'Diesen Namen gibt es schon.', + felder: { name: 'Diesen Namen gibt es schon.' }, + } + } + + const projekt = await db.projekt.create({ + data: gelesen.data, + select: { id: true, name: true }, + }) + + await auditSchreiben({ + akteur: { id: ich.id, email: ich.email }, + aktion: 'projekt.angelegt', + objektTyp: 'Projekt', + objektId: projekt.id, + details: { name: projekt.name }, + }) + + revalidatePath('/projekte') + return { ok: true, id: projekt.id, meldung: `Projekt „${projekt.name}" angelegt.` } +} + +export async function projektAendern(id: string, daten: FormData): Promise { + let ich + try { + ich = await akteur() + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const gelesen = projektSchema.safeParse(Object.fromEntries(daten.entries())) + if (!gelesen.success) { + return { + ok: false, + fehler: 'Bitte die markierten Felder prüfen.', + felder: feldFehler(gelesen.error), + } + } + + const vorher = await db.projekt.findUnique({ + where: { id }, + select: { id: true, name: true, status: true }, + }) + if (!vorher) return { ok: false, fehler: 'Dieses Projekt gibt es nicht mehr.' } + + const dublette = await db.projekt.findFirst({ + where: { name: gelesen.data.name, id: { not: id } }, + select: { id: true }, + }) + if (dublette) { + return { + ok: false, + fehler: 'Diesen Namen gibt es schon.', + felder: { name: 'Diesen Namen gibt es schon.' }, + } + } + + await db.projekt.update({ where: { id }, data: gelesen.data }) + + await auditSchreiben({ + akteur: { id: ich.id, email: ich.email }, + aktion: 'projekt.geaendert', + objektTyp: 'Projekt', + objektId: id, + details: { + vorher: vorher.name, + nachher: gelesen.data.name, + statusVorher: vorher.status, + statusNachher: gelesen.data.status, + }, + }) + + revalidatePath('/projekte') + revalidatePath(`/projekte/${id}`) + return { ok: true, id, meldung: `Projekt „${gelesen.data.name}" gespeichert.` } +} + +/** + * Löscht ein Projekt. Die enthaltenen Aufgaben bleiben bestehen und verlieren + * nur ihre Zuordnung — sonst verschwände mit dem Projekt auch die Arbeit. + */ +export async function projektLoeschen(daten: FormData): Promise { + let ich + try { + ich = await akteur() + } catch (fehler) { + if (fehler instanceof NichtErlaubt) return { ok: false, fehler: fehler.message } + throw fehler + } + + const id = daten.get('id') + if (typeof id !== 'string' || id.length === 0) { + return { ok: false, fehler: 'Ungültige Anfrage.' } + } + + const projekt = await db.projekt.findUnique({ + where: { id }, + select: { id: true, name: true, _count: { select: { aufgaben: true } } }, + }) + if (!projekt) return { ok: false, fehler: 'Dieses Projekt gibt es nicht mehr.' } + + await db.projekt.delete({ where: { id: projekt.id } }) + + await auditSchreiben({ + akteur: { id: ich.id, email: ich.email }, + aktion: 'projekt.geloescht', + objektTyp: 'Projekt', + objektId: projekt.id, + details: { name: projekt.name, aufgabenGeloest: projekt._count.aufgaben }, + }) + + revalidatePath('/projekte') + revalidatePath('/aufgaben') + return { + ok: true, + meldung: + projekt._count.aufgaben > 0 + ? `Projekt „${projekt.name}" gelöscht. ${projekt._count.aufgaben} Aufgabe(n) bleiben erhalten.` + : `Projekt „${projekt.name}" gelöscht.`, + } +}