feat(aufgaben): Server Actions und Abfrageschicht
Anlegen, ändern, Status setzen, einsortieren, aus einer Idee eine Aufgabe machen und löschen — jede Aktion prüft die Rolle selbst und hinterlässt einen Audit-Eintrag. Statuswechsel gehen nur die Wege, die uebergangErlaubt() zulässt: Abgeschlossenes wird erst wieder geöffnet, bevor es weitergeht. Wer per Formular etwas anderes einreicht, bekommt eine Meldung statt eines stillen Zustandssprungs. Sortiert wird in der Anwendung, nicht in SQL. Die Regel ist mehrstufig und steht als geprüfte reine Funktion in src/lib — zwei Fassungen derselben Regel wären eine Fehlerquelle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
63c3c15a3c
commit
b3ca1c9ce7
@@ -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<AufgabeAnzeige[]> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<string, string> }
|
||||
|
||||
function feldFehler(fehler: z.ZodError): Record<string, string> {
|
||||
const felder: Record<string, string> = {}
|
||||
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<ReturnType<typeof pruefeRolle>> } | { 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<string, unknown> {
|
||||
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<Record<string, string> | null> {
|
||||
const felder: Record<string, string> = {}
|
||||
|
||||
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<Rueckmeldung> {
|
||||
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<Rueckmeldung> {
|
||||
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<Rueckmeldung> {
|
||||
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<Rueckmeldung> {
|
||||
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<Rueckmeldung> {
|
||||
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<Rueckmeldung> {
|
||||
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<Rueckmeldung> {
|
||||
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.` }
|
||||
}
|
||||
@@ -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<string, string> }
|
||||
|
||||
function feldFehler(fehler: z.ZodError): Record<string, string> {
|
||||
const felder: Record<string, string> = {}
|
||||
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<Rueckmeldung> {
|
||||
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<Rueckmeldung> {
|
||||
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<Rueckmeldung> {
|
||||
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.`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user