feat(tasks): Aufgaben je Projekt mit Spalten und Reihenfolge

Neues Modell Task: Titel, optionale Beschreibung, Spalte, Position,
Faelligkeit und Labels. Vier Spalten - offen, in Arbeit, blockiert,
erledigt.

Anders als beim Projektbrett ist die Reihenfolge innerhalb einer Spalte hier
bedeutsam und wird deshalb gespeichert. Der Reorder-Endpunkt nimmt je Spalte
die vollstaendige Liste in ihrer neuen Reihenfolge entgegen und leitet Status
und Position daraus ab. Das ist wiederholbar, kommt ohne Positionsrechnerei
im Browser aus, und ein verlorener Aufruf laesst sich einfach erneut senden.

Sicherheitsrelevant: der Endpunkt bekommt IDs aus dem Browser. Er
aktualisiert ausschliesslich Aufgaben, die wirklich zu diesem Projekt
gehoeren, und meldet uebergangene IDs zurueck - sonst liessen sich fremde
Aufgaben ueber untergeschobene IDs verschieben.

doneAt wird nur beim tatsaechlichen Wechsel nach "erledigt" gesetzt und beim
Zurueckziehen geleert. Ein Umsortieren innerhalb der Spalte laesst das Datum
in Ruhe.

Der Fortschritt eines Projekts laesst sich wahlweise aus dem Anteil
erledigter Aufgaben ableiten. Er wird nach jeder Aenderung neu berechnet und
auch sofort beim Einschalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 09:29:28 +02:00
co-authored by Claude Opus 5
parent 298da72b6d
commit 72bcf824ce
8 changed files with 461 additions and 0 deletions
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import type { Prisma, TaskStatus } from "@prisma/client";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/**
* Neue Reihenfolge nach dem Ziehen.
*
* Uebergeben wird je betroffener Spalte die vollstaendige Liste der Aufgaben
* in ihrer neuen Reihenfolge. Daraus ergeben sich Status und Position
* eindeutig – der Aufruf ist damit wiederholbar, ohne Schaden anzurichten,
* und ein verlorener Aufruf laesst sich einfach erneut senden.
*/
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskReorderSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const columns = parsed.data.columns as Partial<Record<TaskStatus, string[]>>;
const requestedIds = Object.values(columns).flat();
// Nur Aufgaben anfassen, die wirklich zu diesem Projekt gehoeren – sonst
// liessen sich fremde Aufgaben ueber untergeschobene IDs verschieben.
const owned = await prisma.task.findMany({
where: { projectId: id, id: { in: requestedIds } },
select: { id: true, status: true },
});
const ownedIds = new Set(owned.map((task) => task.id));
const previousStatus = new Map(owned.map((task) => [task.id, task.status]));
const now = new Date();
const updates: Prisma.PrismaPromise<unknown>[] = [];
for (const [status, ids] of Object.entries(columns) as Array<[TaskStatus, string[]]>) {
ids.forEach((taskId, index) => {
if (!ownedIds.has(taskId)) return;
const before = previousStatus.get(taskId);
updates.push(
prisma.task.update({
where: { id: taskId },
data: {
status,
position: index,
// doneAt nur beim tatsaechlichen Wechsel setzen oder loeschen,
// damit ein Umsortieren innerhalb von "Erledigt" das Datum nicht
// immer wieder erneuert.
...(before !== status
? { doneAt: status === "DONE" ? now : null }
: {}),
},
}),
);
});
}
if (updates.length > 0) {
await prisma.$transaction(updates);
await refreshDerivedProgress(id);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({
tasks,
updated: updates.length,
ignored: requestedIds.length - updates.length,
});
}
+69
View File
@@ -0,0 +1,69 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, ownedProject, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const tasks = await prisma.task.findMany({
where: { projectId: id },
orderBy: [{ status: "asc" }, { position: "asc" }],
});
return NextResponse.json({ tasks });
}
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (!(await ownedProject(id, auth.user.id))) {
return jsonError("Projekt nicht gefunden", 404);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = taskCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { title, description, status, dueDate, labels } = parsed.data;
const task = await prisma.task.create({
data: {
projectId: id,
title,
description: description ?? null,
status,
// Neue Aufgaben landen unten in ihrer Spalte.
position: await nextPosition(id, status),
dueDate: dueDate ? new Date(dueDate) : null,
labels,
doneAt: status === "DONE" ? new Date() : null,
},
});
await refreshDerivedProgress(id);
// Damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
return NextResponse.json({ task }, { status: 201 });
}
+75
View File
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { nextPosition, refreshDerivedProgress } from "@/lib/tasks";
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 },
});
}
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.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 });
await refreshDerivedProgress(existing.projectId);
return NextResponse.json({ task });
}
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);
return NextResponse.json({ ok: true });
}