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),