diff --git a/prisma/migrations/20260726260000_project_templates/migration.sql b/prisma/migrations/20260726260000_project_templates/migration.sql new file mode 100644 index 0000000..0ba597f --- /dev/null +++ b/prisma/migrations/20260726260000_project_templates/migration.sql @@ -0,0 +1,29 @@ +-- CreateTable +CREATE TABLE "ProjectTemplate" ( + "id" TEXT NOT NULL, + "ownerId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "summary" TEXT, + "projectDescription" TEXT, + "status" "ProjectStatus" NOT NULL DEFAULT 'IDEA', + "priority" INTEGER NOT NULL DEFAULT 2, + "accent" TEXT NOT NULL DEFAULT 'violet', + "tags" TEXT[] DEFAULT ARRAY[]::TEXT[], + "progressFromTasks" BOOLEAN NOT NULL DEFAULT false, + "notes" JSONB NOT NULL DEFAULT '[]', + "tasks" JSONB NOT NULL DEFAULT '[]', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ProjectTemplate_ownerId_idx" ON "ProjectTemplate"("ownerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectTemplate_ownerId_name_key" ON "ProjectTemplate"("ownerId", "name"); + +-- AddForeignKey +ALTER TABLE "ProjectTemplate" ADD CONSTRAINT "ProjectTemplate_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index de585f2..b2d2436 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -74,6 +74,20 @@ export async function POST(request: Request) { if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404); } + // Aus der Vorlage kommen nur Notizen und Aufgaben. Die Stammdaten hat die + // Oberflaeche schon in die Felder uebernommen, damit man sie vor dem + // Speichern noch sieht und aendern kann. + const vorlage = data.templateId + ? await prisma.projectTemplate.findFirst({ + where: { id: data.templateId, ownerId: auth.user.id }, + select: { name: true, notes: true, tasks: true }, + }) + : null; + if (data.templateId && !vorlage) return jsonError("Vorlage nicht gefunden.", 404); + + const notes = templateNotes(vorlage?.notes); + const tasks = templateTasks(vorlage?.tasks); + const angelegt = await prisma.project.create({ data: { ownerId: auth.user.id, @@ -87,8 +101,11 @@ export async function POST(request: Request) { accent: data.accent, tags: data.tags, favorite: data.favorite, + progressFromTasks: data.progressFromTasks, // Hinten an die Statusspalte des Bretts. position: await nextProjectPosition(auth.user.id, data.status), + notes: { create: notes }, + tasks: { create: tasks }, }, select: { id: true }, }); @@ -111,12 +128,56 @@ export async function POST(request: Request) { projectId: project.id, userId: auth.user.id, kind: "PROJECT_CREATED", - summary: - data.status === "IDEA" + summary: vorlage + ? `Aus Vorlage „${vorlage.name}“ angelegt` + : data.status === "IDEA" ? "Als Idee angelegt" : `Angelegt mit Status „${statusOf(data.status).label}“`, - meta: { status: data.status }, + meta: { + status: data.status, + ...(vorlage ? { template: vorlage.name, notes: notes.length, tasks: tasks.length } : {}), + }, }); return NextResponse.json({ project }, { status: 201 }); } + +/** + * Die Vorlage kommt als JSON aus der Datenbank und ist damit fuer TypeScript + * unbekannt. Hier wird sie auf das Noetige zurechtgeschnitten – lieber einen + * Eintrag auslassen als am Anlegen scheitern. + */ +function templateNotes(value: unknown) { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is Record => Boolean(entry) && typeof entry === "object") + .filter((entry) => typeof entry.content === "string" && entry.content.trim().length > 0) + .slice(0, 50) + .map((entry) => ({ + title: typeof entry.title === "string" ? entry.title : null, + content: entry.content as string, + pinned: entry.pinned === true, + })); +} + +function templateTasks(value: unknown) { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is Record => Boolean(entry) && typeof entry === "object") + .filter((entry) => typeof entry.title === "string" && entry.title.trim().length > 0) + .slice(0, 100) + .map((entry, index) => ({ + title: entry.title as string, + description: typeof entry.description === "string" ? entry.description : null, + status: (["TODO", "DOING", "BLOCKED"] as const).includes( + entry.status as "TODO" | "DOING" | "BLOCKED", + ) + ? (entry.status as "TODO" | "DOING" | "BLOCKED") + : ("TODO" as const), + // Reihenfolge wie in der Vorlage. + position: index, + labels: Array.isArray(entry.labels) + ? entry.labels.filter((label): label is string => typeof label === "string").slice(0, 8) + : [], + })); +} diff --git a/src/app/api/templates/[id]/route.ts b/src/app/api/templates/[id]/route.ts new file mode 100644 index 0000000..dfaa9e3 --- /dev/null +++ b/src/app/api/templates/[id]/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError, templateUpdateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +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 prisma.projectTemplate.findFirst({ + where: { id, ownerId: auth.user.id }, + select: { id: true }, + }); + if (!existing) return jsonError("Vorlage nicht gefunden", 404); + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = templateUpdateSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const data = parsed.data; + + if (data.name) { + const doppelt = await prisma.projectTemplate.findFirst({ + where: { ownerId: auth.user.id, name: data.name, id: { not: id } }, + select: { id: true }, + }); + if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409); + } + + const template = await prisma.projectTemplate.update({ where: { id }, data }); + return NextResponse.json({ template }); +} + +export async function DELETE(_request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + + // deleteMany mit Besitzerbedingung: trifft fremde Vorlagen gar nicht erst. + const result = await prisma.projectTemplate.deleteMany({ + where: { id, ownerId: auth.user.id }, + }); + if (result.count === 0) return jsonError("Vorlage nicht gefunden", 404); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/templates/from-project/route.ts b/src/app/api/templates/from-project/route.ts new file mode 100644 index 0000000..ee7fb29 --- /dev/null +++ b/src/app/api/templates/from-project/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError, templateFromProjectSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** + * Aus einem vorhandenen Projekt eine Vorlage machen. + * + * Das ist der Weg, auf dem Vorlagen ueblicherweise entstehen: man richtet ein + * Projekt so ein, wie es passt, und haelt das dann fest. Eine Vorlage von + * Grund auf zu tippen kann man auch – ueber POST /api/templates. + * + * Nicht mitgenommen wird alles Projektbezogene: Name, Slug, Fortschritt, + * Favorit und vor allem die Repositories. Eine Vorlage, die jedes neue + * Projekt an dasselbe Repository haengt, waere eine Falle. + */ +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = templateFromProjectSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const { projectId, name, description, withNotes, withTasks } = parsed.data; + + const project = await prisma.project.findFirst({ + where: { id: projectId, ownerId: auth.user.id }, + include: { + notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] }, + tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] }, + }, + }); + if (!project) return jsonError("Projekt nicht gefunden", 404); + + const doppelt = await prisma.projectTemplate.findFirst({ + where: { ownerId: auth.user.id, name }, + select: { id: true }, + }); + if (doppelt) return jsonError(`Eine Vorlage „${name}“ gibt es bereits.`, 409); + + const template = await prisma.projectTemplate.create({ + data: { + ownerId: auth.user.id, + name, + description: description ?? null, + summary: project.summary, + projectDescription: project.description, + status: project.status, + priority: project.priority, + accent: project.accent, + tags: project.tags, + progressFromTasks: project.progressFromTasks, + notes: withNotes + ? project.notes.map((note) => ({ + title: note.title, + content: note.content, + pinned: note.pinned, + })) + : [], + // Erledigte Aufgaben kommen nicht mit: eine Vorlage beschreibt, was zu + // tun ist, nicht was einmal getan wurde. + tasks: withTasks + ? project.tasks + .filter((task) => task.status !== "DONE") + .map((task) => ({ + title: task.title, + description: task.description, + status: task.status, + labels: task.labels, + })) + : [], + }, + }); + + return NextResponse.json({ template }, { status: 201 }); +} diff --git a/src/app/api/templates/route.ts b/src/app/api/templates/route.ts new file mode 100644 index 0000000..d1f8863 --- /dev/null +++ b/src/app/api/templates/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { jsonError, readJsonBody } from "@/lib/security/request"; +import { formatZodError, templateCreateSchema } from "@/lib/validation"; + +export const dynamic = "force-dynamic"; + +/** Hoechstzahl je Konto. Wer mehr braucht, hat keine Vorlagen mehr, sondern Projekte. */ +const MAX_TEMPLATES = 30; + +export async function GET() { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const templates = await prisma.projectTemplate.findMany({ + where: { ownerId: auth.user.id }, + orderBy: { name: "asc" }, + }); + + return NextResponse.json({ templates }); +} + +export async function POST(request: Request) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const body = await readJsonBody(request); + if (!body.ok) return body.response; + + const parsed = templateCreateSchema.safeParse(body.data); + if (!parsed.success) { + return NextResponse.json( + { error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) }, + { status: 422 }, + ); + } + + const vorhanden = await prisma.projectTemplate.count({ where: { ownerId: auth.user.id } }); + if (vorhanden >= MAX_TEMPLATES) { + return jsonError(`Mehr als ${MAX_TEMPLATES} Vorlagen sind nicht vorgesehen.`, 409); + } + + const data = parsed.data; + + // Der Name ist je Konto eindeutig; ein Duplikat soll eine verstaendliche + // Meldung geben statt eines Datenbankfehlers. + const doppelt = await prisma.projectTemplate.findFirst({ + where: { ownerId: auth.user.id, name: data.name }, + select: { id: true }, + }); + if (doppelt) return jsonError(`Eine Vorlage „${data.name}“ gibt es bereits.`, 409); + + const template = await prisma.projectTemplate.create({ + data: { + ownerId: auth.user.id, + name: data.name, + description: data.description ?? null, + summary: data.summary ?? null, + projectDescription: data.projectDescription ?? null, + status: data.status, + priority: data.priority, + accent: data.accent, + tags: data.tags, + progressFromTasks: data.progressFromTasks, + notes: data.notes, + tasks: data.tasks, + }, + }); + + return NextResponse.json({ template }, { status: 201 }); +} diff --git a/src/components/ProjectDialog.tsx b/src/components/ProjectDialog.tsx index c963ffa..4c154df 100644 --- a/src/components/ProjectDialog.tsx +++ b/src/components/ProjectDialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import { Loader2, RefreshCw, Search } from "lucide-react"; +import { Loader2, RefreshCw, Search, Trash2 } from "lucide-react"; import type { ProjectStatus } from "@prisma/client"; import { Modal } from "@/components/ui/Modal"; import { ACCENTS, PRIORITY_META, STATUS_META, STATUS_ORDER } from "@/lib/status"; @@ -17,6 +17,33 @@ interface ProjectDialogProps { project?: ProjectListItem | null; } +/** Eine Vorlage, so wie sie /api/templates liefert. */ +export interface ProjectTemplateEntry { + id: string; + name: string; + description: string | null; + summary: string | null; + projectDescription: string | null; + status: ProjectStatus; + priority: number; + accent: string; + tags: string[]; + progressFromTasks: boolean; + notes: unknown[]; + tasks: unknown[]; +} + +/** "3 Notizen, 5 Aufgaben" – oder was davon zutrifft. */ +function beschreibeVorlage(vorlage: ProjectTemplateEntry): string { + const teile: string[] = []; + const notizen = Array.isArray(vorlage.notes) ? vorlage.notes.length : 0; + const aufgaben = Array.isArray(vorlage.tasks) ? vorlage.tasks.length : 0; + if (notizen > 0) teile.push(`${notizen} ${notizen === 1 ? "Notiz" : "Notizen"}`); + if (aufgaben > 0) teile.push(`${aufgaben} ${aufgaben === 1 ? "Aufgabe" : "Aufgaben"}`); + if (vorlage.tags.length > 0) teile.push(vorlage.tags.map((tag) => `#${tag}`).join(" ")); + return teile.length > 0 ? teile.join(" · ") : "nur Stammdaten"; +} + interface FormState { name: string; summary: string; @@ -33,6 +60,8 @@ interface FormState { repoName: string; autoSync: boolean; progressFromTasks: boolean; + /** Nur beim Anlegen: aus welcher Vorlage Notizen und Aufgaben kommen. */ + templateId: string; } const EMPTY: FormState = { @@ -51,6 +80,7 @@ const EMPTY: FormState = { repoName: "", autoSync: true, progressFromTasks: false, + templateId: "", }; export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { @@ -60,14 +90,72 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState>({}); + const isEdit = Boolean(project); + + const [templates, setTemplates] = useState([]); + const gewaehlteVorlage = templates.find((entry) => entry.id === form.templateId) ?? null; + + /** + * Die Vorlagen werden erst geladen, wenn der Dialog aufgeht – und nur zum + * Anlegen. Beim Bearbeiten waeren sie nur Ballast. + */ + useEffect(() => { + if (!open || isEdit) return; + let abgebrochen = false; + fetch("/api/templates") + .then((response) => (response.ok ? response.json() : { templates: [] })) + .then((payload) => { + if (!abgebrochen) setTemplates(payload.templates ?? []); + }) + .catch(() => undefined); + return () => { + abgebrochen = true; + }; + }, [open, isEdit]); + + /** + * Uebernimmt die Stammdaten sichtbar in die Felder. Notizen und Aufgaben + * bleiben der Serverseite ueberlassen: sie liessen sich hier nicht + * darstellen, und wer die Vorlage wieder abwaehlt, soll sie auch nicht + * mitgeschleppt bekommen. + * + * Bereits Getipptes wird nicht ueberschrieben – nur leere Felder werden + * gefuellt. + */ + function applyTemplate(templateId: string) { + const vorlage = templates.find((entry) => entry.id === templateId); + if (!vorlage) { + update("templateId", ""); + return; + } + + setForm((previous) => ({ + ...previous, + templateId, + name: previous.name || vorlage.name, + summary: previous.summary || (vorlage.summary ?? ""), + description: previous.description || (vorlage.projectDescription ?? ""), + status: vorlage.status, + priority: vorlage.priority, + accent: vorlage.accent, + tags: previous.tags || vorlage.tags.join(", "), + progressFromTasks: vorlage.progressFromTasks, + })); + } + + async function removeTemplate(vorlage: ProjectTemplateEntry) { + if (!window.confirm(`Vorlage „${vorlage.name}“ loeschen?`)) return; + await fetch(`/api/templates/${vorlage.id}`, { method: "DELETE" }).catch(() => undefined); + setTemplates((current) => current.filter((entry) => entry.id !== vorlage.id)); + update("templateId", ""); + } + const [repoPickerOpen, setRepoPickerOpen] = useState(false); const [repos, setRepos] = useState([]); const [reposLoading, setReposLoading] = useState(false); const [repoFilter, setRepoFilter] = useState(""); const [repoError, setRepoError] = useState(null); - const isEdit = Boolean(project); - useEffect(() => { if (!open) return; setError(null); @@ -96,6 +184,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi repoName: project.repositories[0]?.repoName ?? "", autoSync: project.repositories[0]?.autoSync ?? true, progressFromTasks: project.progressFromTasks, + templateId: "", } : EMPTY, ); @@ -165,6 +254,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi repoName: form.repoName, autoSync: form.autoSync, progressFromTasks: form.progressFromTasks, + // Nur beim Anlegen; beim Bearbeiten kennt das Schema das Feld nicht. + ...(isEdit ? {} : { templateId: form.templateId || null }), }; try { @@ -203,6 +294,52 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi } >
+ {/* Nur beim Anlegen. Ein bestehendes Projekt nachtraeglich aus einer + Vorlage zu befuellen wuerde Notizen und Aufgaben verdoppeln. */} + {!isEdit && templates.length > 0 ? ( +
+ +
+ + {gewaehlteVorlage ? ( + + ) : null} +
+ {gewaehlteVorlage ? ( +

+ {gewaehlteVorlage.description ? `${gewaehlteVorlage.description} · ` : ""} + {beschreibeVorlage(gewaehlteVorlage)} +

+ ) : ( +

+ Vorlagen entstehen aus einem bestehenden Projekt – im Projektkopf unter + „Als Vorlage sichern“. +

+ )} +
+ ) : null} +
+ + + ); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index c25c517..4129d7d 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -3,6 +3,7 @@ import { z } from "zod"; const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const; const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const; const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const; +const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; const emptyToNull = (value: unknown) => typeof value === "string" && value.trim() === "" ? null : value; @@ -31,9 +32,17 @@ export const projectCreateSchema = z.object({ autoSync: z.boolean().default(true), /** Fortschritt aus dem Verhaeltnis erledigter Aufgaben berechnen. */ progressFromTasks: z.boolean().default(false), + /** + * Aus welcher Vorlage. Die Stammdaten hat die Oberflaeche bereits in die + * Felder oben uebernommen – hier zaehlen nur noch Notizen und Aufgaben, + * die sich im Dialog nicht darstellen lassen. + */ + templateId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()), }); -export const projectUpdateSchema = projectCreateSchema.partial(); +// templateId gilt nur beim Anlegen. Ein bestehendes Projekt nachtraeglich +// "aus einer Vorlage" zu befuellen wuerde Notizen und Aufgaben verdoppeln. +export const projectUpdateSchema = projectCreateSchema.partial().omit({ templateId: true }); /** * Neue Reihenfolge im Brett. Uebergeben wird je betroffener Spalte die @@ -70,6 +79,50 @@ export const projectBulkSchema = z path: ["favorite"], }); +// ── Projektvorlagen ────────────────────────────────────────── + +const templateNoteSchema = z.object({ + title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()), + content: z.string().trim().min(1).max(20000), + pinned: z.boolean().optional().default(false), +}); + +const templateTaskSchema = z.object({ + title: z.string().trim().min(1).max(200), + description: z.preprocess(emptyToNull, z.string().trim().max(5000).nullable().optional()), + status: z.enum(TASK_STATUS).optional().default("TODO"), + labels: z.array(z.string().trim().min(1).max(30)).max(8).optional().default([]), +}); + +export const templateCreateSchema = z.object({ + name: z.string().trim().min(1, "Name darf nicht leer sein").max(80), + description: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()), + summary: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()), + projectDescription: z.preprocess( + emptyToNull, + z.string().trim().max(20000).nullable().optional(), + ), + status: z.enum(STATUS).default("IDEA"), + priority: z.coerce.number().int().min(1).max(4).default(2), + accent: z.enum(ACCENTS).default("violet"), + tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]), + progressFromTasks: z.boolean().default(false), + notes: z.array(templateNoteSchema).max(50).default([]), + tasks: z.array(templateTaskSchema).max(100).default([]), +}); + +export const templateUpdateSchema = templateCreateSchema.partial(); + +/** Aus einem vorhandenen Projekt eine Vorlage machen. */ +export const templateFromProjectSchema = z.object({ + projectId: z.string().min(1), + name: z.string().trim().min(1).max(80), + description: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()), + /** Notizen und Aufgaben mitnehmen – oder nur die Stammdaten. */ + withNotes: z.boolean().default(true), + withTasks: z.boolean().default(true), +}); + // ── Repositories ───────────────────────────────────────────── export const repositoryCreateSchema = z.object({ @@ -88,8 +141,6 @@ export const repositoryUpdateSchema = repositoryCreateSchema.partial().extend({ // ── Aufgaben ───────────────────────────────────────────────── -const TASK_STATUS = ["TODO", "DOING", "BLOCKED", "DONE"] as const; - const dueDateField = z.preprocess( emptyToNull, z