feat(projects): Aktivitaetsverlauf je Projekt

Was ist hier eigentlich passiert - eine Zeitleiste auf der Projektseite
beantwortet das jetzt: Statuswechsel, Notizen, Aufgaben, neue Commits.

Der Verlauf soll erzaehlen, nicht protokollieren. Deshalb bekommen Status
und Repo-Verknuepfung einen eigenen Eintrag, waehrend alle uebrigen Felder
zu einem zusammengefasst werden - sonst ertraenkt eine Feinjustage der Farbe
alles Wichtige. Ein Titel, der sich aendert, kommt gar nicht vor.

Beim Abgleich ist Zurueckhaltung noch wichtiger: er laeuft im Zweifel
stuendlich. Vermerkt wird nur, wenn der letzte Commit ein anderer ist als
beim letzten Mal, und beim ersten Abgleich gar nicht - dass ein Repo Commits
hat, ist keine Neuigkeit. Fehlschlaege stehen nur beim Uebergang in den
Fehlerzustand drin, sonst schlaegt ein abgelaufenes Token stuendlich neu auf.

Der Text wird beim Schreiben formuliert, nicht beim Anzeigen. Ein Eintrag
soll auch dann lesbar bleiben, wenn es die Notiz, um die es ging, laengst
nicht mehr gibt.

Zweihundert Eintraege je Projekt bleiben erhalten, der taegliche Durchgang
stutzt den Rest. Der Export nimmt den Verlauf nicht mit - er beschriebe, was
in dieser Instanz passiert ist, und verwiese auf Konten, die es anderswo
nicht gibt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 12:36:19 +02:00
co-authored by Claude Opus 5
parent fa5fd78318
commit af562584ad
20 changed files with 772 additions and 13 deletions
+28
View File
@@ -1,4 +1,5 @@
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";
@@ -44,6 +45,18 @@ export async function PATCH(request: Request, { params }: Params) {
},
});
// Nur Anheften ist keine inhaltliche Aenderung und kommt nicht in den
// Verlauf – sonst steht dort dreimal dieselbe Notiz, weil jemand unschluessig war.
if (parsed.data.title !== undefined || parsed.data.content !== undefined) {
await recordActivity({
projectId: note.projectId,
userId: auth.user.id,
kind: "NOTE_UPDATED",
summary: `Notiz bearbeitet: ${shorten(updated.title || updated.content)}`,
meta: { noteId: id },
});
}
return NextResponse.json({ note: updated });
}
@@ -53,10 +66,25 @@ export async function DELETE(_request: Request, { params }: Params) {
const { id } = await params;
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
const vorher = await prisma.note.findFirst({
where: { id, project: { ownerId: auth.user.id } },
select: { projectId: true, title: true, content: true },
});
const result = await prisma.note.deleteMany({
where: { id, project: { ownerId: auth.user.id } },
});
if (result.count === 0) return jsonError("Notiz nicht gefunden", 404);
if (vorher) {
await recordActivity({
projectId: vorher.projectId,
userId: auth.user.id,
kind: "NOTE_DELETED",
summary: `Notiz gelöscht: ${shorten(vorher.title || vorher.content)}`,
});
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { ACTIVITY_LIMIT } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Der Verlauf eines Projekts, neueste Eintraege zuerst. */
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);
}
// Number(null) waere 0 und damit endlich – ohne die Pruefung auf den
// fehlenden Parameter kaeme genau ein Eintrag zurueck.
const roh = new URL(request.url).searchParams.get("limit");
const angefragt = roh === null ? Number.NaN : Number(roh);
const take = Number.isFinite(angefragt)
? Math.min(Math.max(angefragt, 1), ACTIVITY_LIMIT)
: 30;
const activities = await prisma.activity.findMany({
where: { projectId: id },
orderBy: { createdAt: "desc" },
take,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
user: { select: { username: true, displayName: true } },
},
});
return NextResponse.json({ activities });
}
+9
View File
@@ -1,4 +1,5 @@
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";
@@ -64,5 +65,13 @@ export async function POST(request: Request, { params }: Params) {
// Projekt "anfassen", damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "NOTE_ADDED",
summary: `Notiz angelegt: ${shorten(note.title || note.content)}`,
meta: { noteId: note.id },
});
return NextResponse.json({ note }, { status: 201 });
}
+57
View File
@@ -1,4 +1,6 @@
import type { ProjectStatus } from "@prisma/client";
import { NextResponse } from "next/server";
import { describeFields, describeStatus, recordActivities, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git";
@@ -98,6 +100,8 @@ export async function PATCH(request: Request, { params }: Params) {
await prisma.project.update({ where: { id }, data: update });
await noteChanges(id, auth.user.id, existing, update);
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
// erst beim naechsten Aufgabenwechsel.
if (data.progressFromTasks === true) {
@@ -112,6 +116,59 @@ export async function PATCH(request: Request, { params }: Params) {
return NextResponse.json({ project });
}
/**
* Haelt fest, was sich geaendert hat. Status und Repo-Verknuepfung bekommen
* einen eigenen Eintrag – sie sind die Vorgaenge, die man spaeter sucht. Alles
* uebrige fasst ein Eintrag zusammen, sonst ertraenkt eine Feinjustage der
* Farbe den Verlauf.
*/
async function noteChanges(
projectId: string,
userId: string,
before: { status: ProjectStatus; repoUrl: string | null },
update: Record<string, unknown>,
) {
const rest = Object.keys(update).filter(
(key) => key !== "status" && key !== "slug" && !key.startsWith("repo"),
);
const entries: Parameters<typeof recordActivities>[0] = [];
if (update.status !== undefined && update.status !== before.status) {
const to = update.status as ProjectStatus;
entries.push({
projectId,
userId,
kind: "STATUS_CHANGED",
summary: describeStatus(before.status, to),
meta: { from: before.status, to },
});
}
if (update.repoUrl !== undefined && update.repoUrl !== before.repoUrl) {
const url = update.repoUrl as string | null;
entries.push({
projectId,
userId,
kind: "REPO_LINKED",
summary: url ? `Repository verknüpft: ${shorten(url)}` : "Repository-Verknüpfung entfernt",
meta: { repoUrl: url },
});
}
const felder = describeFields(rest);
if (felder) {
entries.push({
projectId,
userId,
kind: "PROJECT_UPDATED",
summary: felder,
meta: { fields: rest },
});
}
await recordActivities(entries);
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -97,6 +98,18 @@ export async function POST(request: Request, { params }: Params) {
if (angelegt.length > 0) {
await refreshDerivedProgress(id);
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
// Ein Eintrag fuer den Vorgang, nicht einer je Aufgabe: sonst besteht der
// Verlauf nach einer Uebernahme aus nichts anderem mehr.
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASKS_IMPORTED",
summary:
angelegt.length === 1
? "1 Issue als Aufgabe übernommen"
: `${angelegt.length} Issues als Aufgaben übernommen`,
meta: { numbers: angelegt.map((task) => task.sourceIssue) },
});
}
return NextResponse.json({
+9
View File
@@ -1,4 +1,5 @@
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";
@@ -65,5 +66,13 @@ export async function POST(request: Request, { params }: Params) {
// Damit die Sortierung nach Aktivitaet stimmt.
await prisma.project.update({ where: { id }, data: { updatedAt: new Date() } });
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "TASK_ADDED",
summary: `Aufgabe angelegt: ${shorten(task.title, 50)}`,
meta: { taskId: task.id },
});
return NextResponse.json({ task }, { status: 201 });
}
+11 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { exportFileSchema, type ExportedProject } from "@/lib/export/format";
@@ -202,7 +203,7 @@ export async function POST(request: Request) {
results.push({ ...base, action: "overwrite" });
} else {
const finalSlug = existing ? await uniqueSlug(incoming.name, auth.user.id) : slug;
await prisma.project.create({
const angelegt = await prisma.project.create({
data: {
...data,
ownerId: auth.user.id,
@@ -211,6 +212,15 @@ export async function POST(request: Request) {
tasks: { create: tasks },
},
});
// Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt
// wenigstens fest, woher das Projekt stammt.
await recordActivity({
projectId: angelegt.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary: "Aus einer Exportdatei importiert",
meta: { notes: notes.length, tasks: tasks.length },
});
results.push({
...base,
action: existing ? "rename" : "create",
+13
View File
@@ -1,10 +1,12 @@
import { NextResponse } from "next/server";
import { recordActivity } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { parseRepoUrl } from "@/lib/git";
import { projectListInclude, uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { statusOf } from "@/lib/status";
import { formatZodError, projectCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -105,5 +107,16 @@ export async function POST(request: Request) {
include: projectListInclude,
});
await recordActivity({
projectId: project.id,
userId: auth.user.id,
kind: "PROJECT_CREATED",
summary:
data.status === "IDEA"
? "Als Idee angelegt"
: `Angelegt mit Status „${statusOf(data.status).label}“`,
meta: { status: data.status },
});
return NextResponse.json({ project }, { status: 201 });
}
+25 -2
View File
@@ -1,8 +1,10 @@
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 { nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import { TASK_STATUS_META, nextPosition, refreshDerivedProgress } from "@/lib/tasks";
import { formatZodError, taskUpdateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
@@ -13,7 +15,7 @@ type Params = { params: Promise<{ id: string }> };
async function ownedTask(taskId: string, ownerId: string) {
return prisma.task.findFirst({
where: { id: taskId, project: { ownerId } },
select: { id: true, projectId: true, status: true },
select: { id: true, projectId: true, status: true, title: true },
});
}
@@ -57,6 +59,20 @@ export async function PATCH(request: Request, { params }: Params) {
const task = await prisma.task.update({ where: { id }, data: update });
await refreshDerivedProgress(existing.projectId);
// 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: `Aufgabe „${shorten(task.title, 50)}“ → ${TASK_STATUS_META[nach].label}`,
meta: { taskId: id, from: existing.status, to: nach },
});
}
return NextResponse.json({ task });
}
@@ -71,5 +87,12 @@ export async function DELETE(_request: Request, { params }: Params) {
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 });
}
+3 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { FileText } from "lucide-react";
import { ActivityFeed } from "@/components/ActivityFeed";
import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel";
@@ -85,8 +86,9 @@ export default async function ProjectPage({ params }: PageProps) {
<NotesPanel projectId={detail.id} notes={detail.notes} />
</div>
<div className="lg:col-span-7">
<div className="space-y-6 lg:col-span-7">
<GitPanel project={detail} />
<ActivityFeed projectId={detail.id} initial={detail.activities} />
</div>
</div>
</div>