From 72bcf824cecc8c587aa6d5f472ec6262743947f2 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 26 Jul 2026 09:29:28 +0200 Subject: [PATCH] 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) --- .../20260726120000_tasks/migration.sql | 28 ++++++ prisma/schema.prisma | 37 ++++++++ .../api/projects/[id]/tasks/reorder/route.ts | 93 +++++++++++++++++++ src/app/api/projects/[id]/tasks/route.ts | 69 ++++++++++++++ src/app/api/tasks/[id]/route.ts | 75 +++++++++++++++ src/lib/tasks.test.ts | 36 +++++++ src/lib/tasks.ts | 86 +++++++++++++++++ src/lib/validation.ts | 37 ++++++++ 8 files changed, 461 insertions(+) create mode 100644 prisma/migrations/20260726120000_tasks/migration.sql create mode 100644 src/app/api/projects/[id]/tasks/reorder/route.ts create mode 100644 src/app/api/projects/[id]/tasks/route.ts create mode 100644 src/app/api/tasks/[id]/route.ts create mode 100644 src/lib/tasks.test.ts create mode 100644 src/lib/tasks.ts diff --git a/prisma/migrations/20260726120000_tasks/migration.sql b/prisma/migrations/20260726120000_tasks/migration.sql new file mode 100644 index 0000000..4ff7249 --- /dev/null +++ b/prisma/migrations/20260726120000_tasks/migration.sql @@ -0,0 +1,28 @@ +-- CreateEnum +CREATE TYPE "TaskStatus" AS ENUM ('TODO', 'DOING', 'BLOCKED', 'DONE'); + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "progressFromTasks" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "Task" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" "TaskStatus" NOT NULL DEFAULT 'TODO', + "position" INTEGER NOT NULL DEFAULT 0, + "dueDate" TIMESTAMP(3), + "labels" TEXT[] DEFAULT ARRAY[]::TEXT[], + "doneAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Task_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Task_projectId_status_position_idx" ON "Task"("projectId", "status", "position"); + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8cf019a..923d55f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,6 +33,14 @@ enum GitProvider { GITEA } +/// Spalten des Aufgabenbretts innerhalb eines Projekts. +enum TaskStatus { + TODO + DOING + BLOCKED + DONE +} + /// ADMIN darf Benutzer und Einstellungen verwalten. Auf fremde Projekte hat /// auch ein Admin keinen Zugriff – die Trennung ist bewusst hart. enum UserRole { @@ -237,7 +245,12 @@ model Project { ownerId String? owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + /// Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen statt + /// von Hand zu schieben. Nur sinnvoll, wenn es ueberhaupt Aufgaben gibt. + progressFromTasks Boolean @default(false) + notes Note[] + tasks Task[] snapshot RepoSnapshot? createdAt DateTime @default(now()) @@ -250,6 +263,30 @@ model Project { @@index([ownerId]) } +/// Eine Aufgabe innerhalb eines Projekts – die Karten des Aufgabenbretts. +model Task { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + title String + /// Optionale Notiz zur Aufgabe, Markdown wird dargestellt. + description String? + status TaskStatus @default(TODO) + /// Rang innerhalb der Spalte. Anders als beim Projektbrett braucht es ihn + /// hier wirklich: Aufgaben haben eine Reihenfolge, die der Benutzer setzt. + /// Beim Umsortieren werden die Positionen einer Spalte neu durchnummeriert. + position Int @default(0) + dueDate DateTime? + labels String[] @default([]) + /// Zeitpunkt, zu dem die Aufgabe nach DONE gewandert ist. + doneAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([projectId, status, position]) +} + /// Freie Notizen zu einem Projekt. model Note { id String @id @default(cuid()) diff --git a/src/app/api/projects/[id]/tasks/reorder/route.ts b/src/app/api/projects/[id]/tasks/reorder/route.ts new file mode 100644 index 0000000..2695efe --- /dev/null +++ b/src/app/api/projects/[id]/tasks/reorder/route.ts @@ -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>; + 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[] = []; + + 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, + }); +} diff --git a/src/app/api/projects/[id]/tasks/route.ts b/src/app/api/projects/[id]/tasks/route.ts new file mode 100644 index 0000000..34fe489 --- /dev/null +++ b/src/app/api/projects/[id]/tasks/route.ts @@ -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 }); +} diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts new file mode 100644 index 0000000..0d9a856 --- /dev/null +++ b/src/app/api/tasks/[id]/route.ts @@ -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 = {}; + + 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 }); +} diff --git a/src/lib/tasks.test.ts b/src/lib/tasks.test.ts new file mode 100644 index 0000000..8272f18 --- /dev/null +++ b/src/lib/tasks.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { TASK_STATUS_META, TASK_STATUS_ORDER, progressFromTasks } from "./tasks"; + +describe("progressFromTasks", () => { + it("rechnet den Anteil erledigter Aufgaben in Prozent um", () => { + expect(progressFromTasks(4, 1)).toBe(25); + expect(progressFromTasks(8, 3)).toBe(38); + expect(progressFromTasks(3, 3)).toBe(100); + expect(progressFromTasks(5, 0)).toBe(0); + }); + + it("gibt null zurueck, wenn es nichts zu rechnen gibt", () => { + // Ohne Aufgaben bleibt der von Hand gesetzte Wert stehen. + expect(progressFromTasks(0, 0)).toBeNull(); + expect(progressFromTasks(-1, 0)).toBeNull(); + }); + + it("rundet kaufmaennisch statt abzuschneiden", () => { + // 1/3 sind 33,33 % und 2/3 sind 66,67 %. + expect(progressFromTasks(3, 1)).toBe(33); + expect(progressFromTasks(3, 2)).toBe(67); + }); +}); + +describe("Spaltenreihenfolge", () => { + it("fuehrt von offen nach erledigt", () => { + expect(TASK_STATUS_ORDER).toEqual(["TODO", "DOING", "BLOCKED", "DONE"]); + }); + + it("hat zu jeder Spalte eine Beschriftung und eine Farbe", () => { + for (const status of TASK_STATUS_ORDER) { + expect(TASK_STATUS_META[status].label).toBeTruthy(); + expect(TASK_STATUS_META[status].color).toMatch(/^#[0-9a-f]{6}$/i); + } + }); +}); diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts new file mode 100644 index 0000000..b3c8a55 --- /dev/null +++ b/src/lib/tasks.ts @@ -0,0 +1,86 @@ +import type { TaskStatus } from "@prisma/client"; +import { prisma } from "@/lib/db"; + +/** Spaltenreihenfolge des Aufgabenbretts. */ +export const TASK_STATUS_ORDER: TaskStatus[] = ["TODO", "DOING", "BLOCKED", "DONE"]; + +export const TASK_STATUS_META: Record< + TaskStatus, + { label: string; color: string; className: string } +> = { + TODO: { + label: "Offen", + color: "#94a3b8", + className: "bg-slate-500/12 text-slate-300 ring-slate-400/25", + }, + DOING: { + label: "In Arbeit", + color: "#a78bfa", + className: "bg-violet-500/12 text-violet-200 ring-violet-400/30", + }, + BLOCKED: { + label: "Blockiert", + color: "#fb7185", + className: "bg-rose-500/12 text-rose-200 ring-rose-400/30", + }, + DONE: { + label: "Erledigt", + color: "#34d399", + className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30", + }, +}; + +/** + * Berechnet den Fortschritt aus dem Verhaeltnis erledigter Aufgaben. + * Ohne Aufgaben gibt es nichts zu berechnen – dann bleibt der Wert, wie er ist. + */ +export function progressFromTasks(total: number, done: number): number | null { + if (total <= 0) return null; + return Math.round((done / total) * 100); +} + +/** + * Schreibt den abgeleiteten Fortschritt, falls das Projekt es so eingestellt + * hat. Wird nach jeder Aenderung an Aufgaben aufgerufen. + */ +export async function refreshDerivedProgress(projectId: string): Promise { + const project = await prisma.project.findUnique({ + where: { id: projectId }, + select: { progressFromTasks: true }, + }); + if (!project?.progressFromTasks) return; + + const [total, done] = await Promise.all([ + prisma.task.count({ where: { projectId } }), + prisma.task.count({ where: { projectId, status: "DONE" } }), + ]); + + const value = progressFromTasks(total, done); + if (value === null) return; + + await prisma.project.update({ + where: { id: projectId }, + data: { progress: value }, + }); +} + +/** Naechste freie Position am Ende einer Spalte. */ +export async function nextPosition( + projectId: string, + status: TaskStatus, +): Promise { + const last = await prisma.task.findFirst({ + where: { projectId, status }, + orderBy: { position: "desc" }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; +} + +/** Prueft Existenz und Besitz in einem Zug. */ +export async function ownedProject(projectId: string, ownerId: string) { + return prisma.project.findFirst({ + where: { id: projectId, ownerId }, + select: { id: true }, + }); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 4c4440d..7dc5440 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -23,10 +23,47 @@ export const projectCreateSchema = z.object({ connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()), /** Am automatischen Abgleich teilnehmen. */ autoSync: z.boolean().default(true), + /** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */ + progressFromTasks: z.boolean().default(false), }); export const projectUpdateSchema = projectCreateSchema.partial(); +// ── Aufgaben ───────────────────────────────────────────────── + +const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; + +const dueDateField = z.preprocess( + emptyToNull, + z + .string() + .trim() + .refine((value) => !Number.isNaN(Date.parse(value)), "Kein gueltiges Datum") + .nullable() + .optional(), +); + +export const taskCreateSchema = z.object({ + title: z.string().trim().min(1, "Titel darf nicht leer sein").max(200), + description: z.preprocess(emptyToNull, z.string().trim().max(5000).nullable().optional()), + status: z.enum(TASK_STATUS).default("TODO"), + dueDate: dueDateField, + labels: z.array(z.string().trim().min(1).max(30)).max(8).default([]), +}); + +export const taskUpdateSchema = taskCreateSchema.partial(); + +/** + * 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. + */ +export const taskReorderSchema = z.object({ + columns: z + .record(z.enum(TASK_STATUS), z.array(z.string().min(1)).max(500)) + .refine((value) => Object.keys(value).length > 0, "Keine Spalte angegeben"), +}); + export const noteCreateSchema = z.object({ title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()), content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000),