Die Zuordnung stand im Schema laengst – sourceIssue, sourceUrl und sourceRepositoryId samt Eindeutigkeit je Repository, gefuellt von der Uebernahme aus Issues seit v0.9.0. Es fehlte die Gegenrichtung. Ein Knopf im Aufgabendialog legt das Issue an; Nummer und Adresse landen in denselben Feldern, der Dublettenschutz gilt also fuer beide Wege. In den Rumpf kommt ein Rueckverweis auf das Projekt. Ausdruecklich per Knopf und nie nebenbei: was in ein fremdes System geschrieben wird, soll jemand gewollt haben. Ist die Aufgabe erst verknuepft, wandert der Zustand allerdings mit – erledigt schliesst das Issue, ein Zurueckziehen oeffnet es wieder. Fehlertolerant: ein nicht erreichbares Git-System darf das Abhaken nicht verhindern. Die Zustaende passen nicht aufeinander, und das bleibt so. Nebula kennt vier, ein Issue zwei; nach aussen geht nur die Grenze um ERLEDIGT. Ein Rundlauf verliert damit IN ARBEIT und BLOCKIERT. Labels waeren die Alternative, wuerden aber den Issue-Tracker mit Zustaenden fuellen, die dort niemand pflegt. Kein Widerspruch zu "keine eigenen Git-Operationen": Issues laufen ueber die HTTP-API der Provider. Dafuer gibt es jetzt sendJson neben getJson – durch dieselbe SSRF-Schleuse, aber ohne Umleitungen zu folgen. Ein 307 auf eine interne Adresse naehme den Rumpf und den Token im Header mit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
4.9 KiB
TypeScript
134 lines
4.9 KiB
TypeScript
import type { TaskStatus } from "@prisma/client";
|
||
import { NextResponse } from "next/server";
|
||
import { recordActivity, shorten } from "@/lib/activity";
|
||
import { requireUserApi } from "@/lib/auth/guard";
|
||
import { prisma } from "@/lib/db";
|
||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||
import {
|
||
TASK_STATUS_META,
|
||
nextPosition,
|
||
refreshDerivedProgress,
|
||
spawnRecurrence,
|
||
} from "@/lib/tasks";
|
||
import { issueShouldBeOpen, mirrorStatusToIssue } from "@/lib/tasks/issues";
|
||
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
type Params = { params: Promise<{ id: string }> };
|
||
|
||
/** Eine Aufgabe gehoert dem, dem das zugehoerige Projekt gehoert. */
|
||
async function ownedTask(taskId: string, ownerId: string) {
|
||
return prisma.task.findFirst({
|
||
where: { id: taskId, project: { ownerId } },
|
||
select: { id: true, projectId: true, status: true, title: true },
|
||
});
|
||
}
|
||
|
||
export async function PATCH(request: Request, { params }: Params) {
|
||
const auth = await requireUserApi();
|
||
if (!auth.ok) return auth.response;
|
||
|
||
const { id } = await params;
|
||
const existing = await ownedTask(id, auth.user.id);
|
||
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
|
||
|
||
const body = await readJsonBody(request);
|
||
if (!body.ok) return body.response;
|
||
|
||
const parsed = taskUpdateSchema.safeParse(body.data);
|
||
if (!parsed.success) {
|
||
return NextResponse.json(
|
||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||
{ status: 422 },
|
||
);
|
||
}
|
||
|
||
const data = parsed.data;
|
||
const update: Record<string, unknown> = {};
|
||
|
||
if (data.title !== undefined) update.title = data.title;
|
||
if (data.description !== undefined) update.description = data.description;
|
||
if (data.labels !== undefined) update.labels = data.labels;
|
||
if (data.recurrence !== undefined) update.recurrence = data.recurrence;
|
||
if (data.dueDate !== undefined) {
|
||
update.dueDate = data.dueDate ? new Date(data.dueDate) : null;
|
||
}
|
||
|
||
if (data.status !== undefined && data.status !== existing.status) {
|
||
update.status = data.status;
|
||
// Beim Spaltenwechsel ans Ende der neuen Spalte.
|
||
update.position = await nextPosition(existing.projectId, data.status);
|
||
// doneAt nur setzen, wenn die Aufgabe wirklich nach DONE wandert.
|
||
update.doneAt = data.status === "DONE" ? new Date() : null;
|
||
}
|
||
|
||
const task = await prisma.task.update({ where: { id }, data: update });
|
||
|
||
// Genau beim Uebergang nach ERLEDIGT entsteht die naechste Fassung. Ein
|
||
// erneutes Speichern einer schon erledigten Aufgabe darf keine zweite
|
||
// erzeugen – deshalb haengt es an update.status und nicht an data.status.
|
||
const folge =
|
||
update.status === "DONE" && existing.status !== "DONE"
|
||
? await spawnRecurrence(id)
|
||
: null;
|
||
|
||
await refreshDerivedProgress(existing.projectId);
|
||
|
||
/**
|
||
* Haengt die Aufgabe an einem Issue, wandert der Zustand mit: erledigt
|
||
* schliesst es, ein Zurueckziehen oeffnet es wieder. Nur die Grenze um
|
||
* ERLEDIGT geht nach draussen – mehr kennt ein Issue nicht.
|
||
*
|
||
* Fehlertolerant und ohne den Aufrufer warten zu lassen, was schiefgehen
|
||
* kann: ein nicht erreichbares Git-System darf das Abhaken nicht
|
||
* verhindern. Beim naechsten Wechsel wird es erneut versucht.
|
||
*/
|
||
if (update.status !== undefined) {
|
||
const vorher = issueShouldBeOpen(existing.status);
|
||
const nachher = issueShouldBeOpen(update.status as TaskStatus);
|
||
if (vorher !== nachher) {
|
||
await mirrorStatusToIssue(id, update.status as TaskStatus);
|
||
}
|
||
}
|
||
|
||
// Nur der Spaltenwechsel kommt in den Verlauf. Ein umbenannter Titel oder
|
||
// ein neues Label sind zu kleinteilig, um die Geschichte des Projekts zu
|
||
// erzaehlen – und beim Ziehen im Brett ist der Wechsel die eigentliche Tat.
|
||
if (update.status !== undefined) {
|
||
const nach = update.status as TaskStatus;
|
||
await recordActivity({
|
||
projectId: existing.projectId,
|
||
userId: auth.user.id,
|
||
kind: "TASK_MOVED",
|
||
summary: folge
|
||
? `Aufgabe „${shorten(task.title, 40)}“ erledigt, nächste angelegt`
|
||
: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
|
||
meta: { taskId: id, from: existing.status, to: nach, ...(folge ? { next: folge.id } : {}) },
|
||
});
|
||
}
|
||
|
||
return NextResponse.json({ task, next: folge });
|
||
}
|
||
|
||
export async function DELETE(_request: Request, { params }: Params) {
|
||
const auth = await requireUserApi();
|
||
if (!auth.ok) return auth.response;
|
||
|
||
const { id } = await params;
|
||
const existing = await ownedTask(id, auth.user.id);
|
||
if (!existing) return jsonError("Aufgabe nicht gefunden", 404);
|
||
|
||
await prisma.task.delete({ where: { id } });
|
||
await refreshDerivedProgress(existing.projectId);
|
||
|
||
await recordActivity({
|
||
projectId: existing.projectId,
|
||
userId: auth.user.id,
|
||
kind: "TASK_DELETED",
|
||
summary: `Aufgabe gelöscht: ${shorten(existing.title, 50)}`,
|
||
});
|
||
|
||
return NextResponse.json({ ok: true });
|
||
}
|