feat: API v1 fuer Aufgaben

Lesen, erfassen, aendern — mehr nicht. Status, Bereich und Projekt lassen
sich von aussen nicht setzen: Was hereinkommt, landet in der Inbox und
wird hier einsortiert, sonst raeumt ein fremder Dienst die eigene Ordnung
um.

Mit einer Kennung ist das Erfassen wiederholbar: Zweimal dasselbe ergibt
eine Aufgabe, nicht zwei. Das ist der Unterschied zwischen einer
Schnittstelle, die man gefahrlos wiederholen kann, und einer, die bei
jedem Netzhaenger Karteileichen anlegt.

Jede schreibende Nutzung steht im Protokoll, mitsamt dem Token. Lesen
nicht: Ein Dienst, der im Minutentakt abfragt, wuerde das Protokoll
unbrauchbar machen.

Alle Fehlschlaege sehen von aussen gleich aus. Ob ein Token unbekannt,
abgelaufen oder widerrufen ist, geht niemanden etwas an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-12 11:09:05 +02:00
co-authored by Claude Opus 5
parent d5c7974af3
commit 6d3ebd05ae
5 changed files with 569 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
import { aenderungEingabe } from '@/lib/api'
import type { Prisma } from '@/generated/prisma/client'
import { db } from '@/server/db'
import { abgelehnt, apiProtokoll, daten, rumpfLesen, wache } from '@/server/api/wache'
import { jetzt } from '@/server/zeitgeber'
export const dynamic = 'force-dynamic'
/**
* Eine einzelne Aufgabe lesen und ändern.
*
* Ändern heißt: Titel, Notiz, Fälligkeit, Priorität, Aufwand — und abhaken.
* Bereich und Projekt bleiben außen vor; die Einordnung ist Sache dessen, der
* plant, nicht dessen, der meldet.
*/
const AUSWAHL = {
id: true,
titel: true,
notiz: true,
status: true,
prioritaet: true,
aufwandMinuten: true,
faelligAm: true,
quelle: true,
quelleRef: true,
quelleUrl: true,
erstelltAm: true,
bereich: { select: { name: true } },
projekt: { select: { name: true } },
} as const
type Zeile = {
id: string
titel: string
notiz: string | null
status: string
prioritaet: number
aufwandMinuten: number | null
faelligAm: Date | null
quelle: string
quelleRef: string | null
quelleUrl: string | null
erstelltAm: Date
bereich: { name: string } | null
projekt: { name: string } | null
}
function alsAntwort(a: Zeile) {
return {
id: a.id,
titel: a.titel,
notiz: a.notiz,
status: a.status,
prioritaet: a.prioritaet,
aufwandMinuten: a.aufwandMinuten,
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
quelle: a.quelle,
ref: a.quelleRef,
url: a.quelleUrl,
erstelltAm: a.erstelltAm.toISOString(),
bereich: a.bereich?.name ?? null,
projekt: a.projekt?.name ?? null,
}
}
export async function GET(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const geprueft = await wache(anfrage, 'lesen')
if (!geprueft.ok) return geprueft.antwort
const { id } = await params
const aufgabe = await db.aufgabe.findUnique({ where: { id }, select: AUSWAHL })
if (!aufgabe) return abgelehnt('nichtGefunden')
return daten(alsAntwort(aufgabe))
}
export async function PATCH(
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const geprueft = await wache(anfrage, 'schreiben')
if (!geprueft.ok) return geprueft.antwort
const { aufrufer } = geprueft
const { id } = await params
const rumpf = await rumpfLesen(anfrage)
if (rumpf === undefined) return abgelehnt('ungueltig')
const gelesen = aenderungEingabe.safeParse(rumpf)
if (!gelesen.success) {
return daten(
{
fehler: 'Die Anfrage war fehlerhaft.',
felder: gelesen.error.issues.map((f) => ({
feld: f.path.join('.'),
grund: f.message,
})),
},
400,
)
}
const vorhanden = await db.aufgabe.findUnique({
where: { id },
select: { id: true, status: true },
})
if (!vorhanden) return abgelehnt('nichtGefunden')
const { erledigt, faelligAm, titel, notiz, prioritaet, aufwandMinuten } = gelesen.data
const zeitpunkt = jetzt()
// Feld fuer Feld statt Spread: Mit exactOptionalPropertyTypes ist ein
// gesetztes "undefined" etwas anderes als ein fehlendes Feld, und Prisma
// wuerde daraus einen Schreibversuch machen.
const werte: Prisma.AufgabeUpdateInput = {}
if (titel !== undefined) werte.titel = titel
if (notiz !== undefined) werte.notiz = notiz
if (prioritaet !== undefined) werte.prioritaet = prioritaet
if (aufwandMinuten !== undefined) werte.aufwandMinuten = aufwandMinuten
if (faelligAm !== undefined) {
werte.faelligAm = faelligAm === null ? null : new Date(faelligAm)
}
if (erledigt !== undefined) {
werte.status = erledigt ? 'ERLEDIGT' : 'OFFEN'
werte.erledigtAm = erledigt ? zeitpunkt : null
}
const aufgabe = await db.aufgabe.update({
where: { id: vorhanden.id },
data: werte,
select: AUSWAHL,
})
await apiProtokoll(aufrufer, 'api.geaendert', aufgabe.id, {
felder: Object.keys(gelesen.data),
})
return daten(alsAntwort(aufgabe))
}
+151
View File
@@ -0,0 +1,151 @@
import { abfrage, aufgabeEingabe } from '@/lib/api'
import type { Prisma } from '@/generated/prisma/client'
import { db } from '@/server/db'
import { abgelehnt, apiProtokoll, daten, rumpfLesen, wache } from '@/server/api/wache'
export const dynamic = 'force-dynamic'
/**
* Aufgaben lesen und anlegen.
*
* Was hier hereinkommt, landet in der **Inbox**: ohne Bereich, ohne Projekt,
* mit Quelle `API`. Ein fremder Dienst soll melden, was es zu tun gibt — wo
* es hingehört, wird hier entschieden.
*/
/** Wie eine Aufgabe nach außen aussieht. Bewusst schmal und stabil. */
function alsAntwort(a: {
id: string
titel: string
notiz: string | null
status: string
prioritaet: number
aufwandMinuten: number | null
faelligAm: Date | null
quelle: string
quelleRef: string | null
quelleUrl: string | null
erstelltAm: Date
bereich: { name: string } | null
projekt: { name: string } | null
}) {
return {
id: a.id,
titel: a.titel,
notiz: a.notiz,
status: a.status,
prioritaet: a.prioritaet,
aufwandMinuten: a.aufwandMinuten,
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
quelle: a.quelle,
ref: a.quelleRef,
url: a.quelleUrl,
erstelltAm: a.erstelltAm.toISOString(),
bereich: a.bereich?.name ?? null,
projekt: a.projekt?.name ?? null,
}
}
const AUSWAHL = {
id: true,
titel: true,
notiz: true,
status: true,
prioritaet: true,
aufwandMinuten: true,
faelligAm: true,
quelle: true,
quelleRef: true,
quelleUrl: true,
erstelltAm: true,
bereich: { select: { name: true } },
projekt: { select: { name: true } },
} as const
export async function GET(anfrage: Request) {
const geprueft = await wache(anfrage, 'lesen')
if (!geprueft.ok) return geprueft.antwort
const suche = new URL(anfrage.url).searchParams
const gelesen = abfrage.safeParse({
grenze: suche.get('grenze') ?? undefined,
versatz: suche.get('versatz') ?? undefined,
offen: suche.get('offen'),
})
if (!gelesen.success) return abgelehnt('ungueltig')
const { grenze, versatz, offen } = gelesen.data
const wo: Prisma.AufgabeWhereInput =
offen === null
? {}
: offen
? { status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } }
: { status: { in: ['ERLEDIGT', 'VERWORFEN'] } }
const [zeilen, gesamt] = await Promise.all([
db.aufgabe.findMany({
where: wo,
orderBy: [{ faelligAm: { sort: 'asc', nulls: 'last' } }, { erstelltAm: 'desc' }],
take: grenze,
skip: versatz,
select: AUSWAHL,
}),
db.aufgabe.count({ where: wo }),
])
// Lesen wird nicht protokolliert: Es ändert nichts, und ein Dienst, der im
// Minutentakt abfragt, würde das Protokoll unbrauchbar machen.
return daten({ aufgaben: zeilen.map(alsAntwort), gesamt, grenze, versatz })
}
export async function POST(anfrage: Request) {
const geprueft = await wache(anfrage, 'erfassen')
if (!geprueft.ok) return geprueft.antwort
const { aufrufer } = geprueft
const rumpf = await rumpfLesen(anfrage)
if (rumpf === undefined) return abgelehnt('ungueltig')
const gelesen = aufgabeEingabe.safeParse(rumpf)
if (!gelesen.success) {
return daten(
{
fehler: 'Die Anfrage war fehlerhaft.',
felder: gelesen.error.issues.map((f) => ({
feld: f.path.join('.'),
grund: f.message,
})),
},
400,
)
}
const eingabe = gelesen.data
const werte = {
titel: eingabe.titel,
notiz: eingabe.notiz,
quelle: 'API' as const,
quelleUrl: eingabe.url,
// Wie im Formular: Ein reines Datum wird zu Mitternacht UTC. Der Planer
// rechnet Faelligkeiten ohnehin auf Berliner Kalendertage um.
faelligAm: eingabe.faelligAm ? new Date(eingabe.faelligAm) : null,
...(eingabe.prioritaet ? { prioritaet: eingabe.prioritaet } : {}),
...(eingabe.aufwandMinuten ? { aufwandMinuten: eingabe.aufwandMinuten } : {}),
}
// Mit Kennung ist der Aufruf wiederholbar: Zweimal dasselbe ergibt eine
// Aufgabe, nicht zwei. Ohne Kennung entsteht jedes Mal eine neue – dann
// hat der Aufrufer es so gewollt.
const aufgabe = eingabe.ref
? await db.aufgabe.upsert({
where: { quelle_quelleRef: { quelle: 'API', quelleRef: eingabe.ref } },
update: { titel: werte.titel, notiz: werte.notiz, quelleUrl: werte.quelleUrl },
create: { ...werte, quelleRef: eingabe.ref },
select: AUSWAHL,
})
: await db.aufgabe.create({ data: werte, select: AUSWAHL })
await apiProtokoll(aufrufer, 'api.erfasst', aufgabe.id, { ref: eingabe.ref })
return daten(alsAntwort(aufgabe), 201)
}
+26
View File
@@ -0,0 +1,26 @@
import { tokenAusKopfzeilen } from '@/lib/api'
import { tokenPruefen } from '@/server/api/token'
import { abgelehnt, daten } from '@/server/api/wache'
export const dynamic = 'force-dynamic'
/**
* „Wer bin ich, und was darf ich?"
*
* Der erste Aufruf beim Einrichten. Er braucht **kein** Recht — sonst müsste
* man raten, ob der Token falsch ist oder nur zu wenig darf, und genau diese
* Ratestunde soll niemand haben.
*/
export async function GET(anfrage: Request) {
const aufrufer = await tokenPruefen(
tokenAusKopfzeilen(
anfrage.headers.get('Authorization'),
anfrage.headers.get('X-Planer-Token'),
),
)
if (!aufrufer) return abgelehnt('nichtAngemeldet')
// Kein Wort über den Benutzer außer dem Namen: Die Schnittstelle ist kein
// Adressbuch.
return daten({ name: aufrufer.name, rechte: aufrufer.rechte })
}