diff --git a/prisma/migrations/20260726210000_activity/migration.sql b/prisma/migrations/20260726210000_activity/migration.sql new file mode 100644 index 0000000..3950b44 --- /dev/null +++ b/prisma/migrations/20260726210000_activity/migration.sql @@ -0,0 +1,24 @@ +-- CreateEnum +CREATE TYPE "ActivityKind" AS ENUM ('PROJECT_CREATED', 'PROJECT_UPDATED', 'STATUS_CHANGED', 'NOTE_ADDED', 'NOTE_UPDATED', 'NOTE_DELETED', 'TASK_ADDED', 'TASK_MOVED', 'TASK_DELETED', 'TASKS_IMPORTED', 'REPO_LINKED', 'REPO_COMMITS', 'SYNC_FAILED'); + +-- CreateTable +CREATE TABLE "Activity" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "userId" TEXT, + "kind" "ActivityKind" NOT NULL, + "summary" TEXT NOT NULL, + "meta" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Activity_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Activity_projectId_createdAt_idx" ON "Activity"("projectId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Activity" ADD CONSTRAINT "Activity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6d73c8c..7a21726 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,25 @@ enum NotificationEvent { PROJECT_STALE } +/// Was im Verlauf eines Projekts festgehalten wird. Bewusst grob: der +/// Verlauf soll erzaehlen, was passiert ist, und nicht jede Feldaenderung +/// mitschreiben. +enum ActivityKind { + PROJECT_CREATED + PROJECT_UPDATED + STATUS_CHANGED + NOTE_ADDED + NOTE_UPDATED + NOTE_DELETED + TASK_ADDED + TASK_MOVED + TASK_DELETED + TASKS_IMPORTED + REPO_LINKED + REPO_COMMITS + SYNC_FAILED +} + /// Spalten des Aufgabenbretts innerhalb eines Projekts. enum TaskStatus { TODO @@ -93,11 +112,21 @@ model User { failedLoginCount Int @default(0) lockedUntil DateTime? + /// TOTP-Schluessel, AES-256-GCM-verschluesselt (siehe src/lib/crypto.ts). + /// Hinterlegt heisst noch nicht scharf: erst ein bestaetigter Code setzt + /// totpEnabledAt. Bis dahin darf die Anmeldung nicht danach fragen, sonst + /// sperrt sich aus, wer die Einrichtung abbricht. + totpSecretCipher String? + totpEnabledAt DateTime? + sessions Session[] credentials Credential[] projects Project[] connections GitConnection[] notificationTargets NotificationTarget[] + activities Activity[] + recoveryCodes RecoveryCode[] + pendingLogins PendingLogin[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -142,6 +171,45 @@ model Credential { @@index([userId]) } +/// Ein Wiederherstellungscode fuer den Fall, dass der Authenticator weg ist. +/// Jeder Code gilt genau einmal. +model RecoveryCode { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// SHA-256 des Codes. Kein langsames Verfahren wie bei Passwoertern: die + /// Codes sind zufaellig erzeugt und lang genug, dass Raten aussichtslos + /// ist – und bei einer Anmeldung waeren sonst zehn teure Vergleiche noetig. + codeHash String @unique + + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId]) +} + +/// Zwischenzustand einer Anmeldung: Passwort stimmt, der zweite Faktor fehlt +/// noch. Bewusst eine eigene Zeile und keine halbfertige Sitzung – so kann +/// aus diesem Zustand nichts entstehen, was einer Anmeldung aehnelt. +model PendingLogin { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + /// Wie bei der Sitzung liegt nur der SHA-256-Hash des Cookie-Tokens hier. + tokenHash String @unique + /// Fehlversuche. Nach zu vielen wird der Vorgang verworfen, damit die + /// sechs Stellen nicht in Ruhe durchprobiert werden koennen. + attempts Int @default(0) + + expiresAt DateTime + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([expiresAt]) +} + /// Kurzlebige WebAuthn-Challenge. Wird nach Gebrauch geloescht, damit sie /// kein zweites Mal verwendet werden kann. model WebAuthnChallenge { @@ -285,9 +353,10 @@ model Project { /// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde. notifiedStaleAt DateTime? - notes Note[] - tasks Task[] - snapshot RepoSnapshot? + notes Note[] + tasks Task[] + snapshot RepoSnapshot? + activities Activity[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -361,6 +430,32 @@ model Task { @@index([projectId, status, position]) } +/// Ein Eintrag im Verlauf eines Projekts: was ist wann passiert. +model Activity { + id String @id @default(cuid()) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + /// Wer. null bedeutet: die Anwendung selbst, also der Zeitgeber beim + /// automatischen Abgleich. + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + kind ActivityKind + /// Fertig formulierter Einzeiler. Die Formulierung beim Schreiben + /// festzuhalten spart der Anzeige das Nachschlagen von Namen, die es + /// spaeter womoeglich nicht mehr gibt – etwa den einer geloeschten Notiz. + summary String + /// Zusatzangaben wie { from: "IDEA", to: "DONE" }. Fuer die Anzeige nicht + /// noetig, aber nuetzlich, falls spaeter ausgewertet werden soll. + meta Json? + + createdAt DateTime @default(now()) + + /// Absteigend gelesen, immer je Projekt. + @@index([projectId, createdAt]) +} + /// Freie Notizen zu einem Projekt. model Note { id String @id @default(cuid()) diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts index 89c8e7d..40822f7 100644 --- a/src/app/api/notes/[id]/route.ts +++ b/src/app/api/notes/[id]/route.ts @@ -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 }); } diff --git a/src/app/api/projects/[id]/activity/route.ts b/src/app/api/projects/[id]/activity/route.ts new file mode 100644 index 0000000..5f9f819 --- /dev/null +++ b/src/app/api/projects/[id]/activity/route.ts @@ -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 }); +} diff --git a/src/app/api/projects/[id]/notes/route.ts b/src/app/api/projects/[id]/notes/route.ts index 48c344e..86a8a94 100644 --- a/src/app/api/projects/[id]/notes/route.ts +++ b/src/app/api/projects/[id]/notes/route.ts @@ -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 }); } diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index 6a70bde..ee61be1 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -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, +) { + const rest = Object.keys(update).filter( + (key) => key !== "status" && key !== "slug" && !key.startsWith("repo"), + ); + const entries: Parameters[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; diff --git a/src/app/api/projects/[id]/tasks/from-issues/route.ts b/src/app/api/projects/[id]/tasks/from-issues/route.ts index f8a8da2..1846d4a 100644 --- a/src/app/api/projects/[id]/tasks/from-issues/route.ts +++ b/src/app/api/projects/[id]/tasks/from-issues/route.ts @@ -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({ diff --git a/src/app/api/projects/[id]/tasks/route.ts b/src/app/api/projects/[id]/tasks/route.ts index 34fe489..cca2fc2 100644 --- a/src/app/api/projects/[id]/tasks/route.ts +++ b/src/app/api/projects/[id]/tasks/route.ts @@ -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 }); } diff --git a/src/app/api/projects/import/route.ts b/src/app/api/projects/import/route.ts index 104a6bd..bde1025 100644 --- a/src/app/api/projects/import/route.ts +++ b/src/app/api/projects/import/route.ts @@ -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", diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index dad912a..0d890ef 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -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 }); } diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 0d9a856..3519d45 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -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 }); } diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index e8cf60d..043f60b 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -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) { -
+
+
diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx new file mode 100644 index 0000000..2e8492b --- /dev/null +++ b/src/components/ActivityFeed.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState } from "react"; +import { + AlertTriangle, + ArrowRight, + Bot, + Download, + GitCommitHorizontal, + History, + Link2, + Loader2, + NotebookPen, + Pencil, + Plus, + Signpost, + Sparkles, + Trash2, +} from "lucide-react"; +import type { ActivityEntry, ActivityKind } from "@/types"; +import { formatDateTime, formatRelative } from "@/lib/utils"; + +/** + * Der Verlauf eines Projekts: was ist wann passiert. + * + * Die erste Seite kommt mit der Seite mit, weitere holt der Knopf nach. Die + * Alternative – alles gleich mitzuladen – wuerde jeden Seitenaufruf mit + * zweihundert Eintraegen belasten, von denen man selten mehr als die + * obersten fuenf liest. + */ + +const KINDS: Record = { + PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300" }, + PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400" }, + STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300" }, + NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300" }, + NOTE_UPDATED: { icon: Pencil, color: "text-blue-300" }, + NOTE_DELETED: { icon: Trash2, color: "text-rose-300" }, + TASK_ADDED: { icon: Plus, color: "text-emerald-300" }, + TASK_MOVED: { icon: ArrowRight, color: "text-violet-300" }, + TASK_DELETED: { icon: Trash2, color: "text-rose-300" }, + TASKS_IMPORTED: { icon: Download, color: "text-amber-300" }, + REPO_LINKED: { icon: Link2, color: "text-cyan-300" }, + REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300" }, + SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300" }, +}; + +export function ActivityFeed({ + projectId, + initial, +}: { + projectId: string; + initial: ActivityEntry[]; +}) { + const [entries, setEntries] = useState(initial); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // Kam eine volle Seite, gibt es womoeglich noch mehr. + const [complete, setComplete] = useState(initial.length < 20); + + async function loadAll() { + setLoading(true); + setError(null); + try { + const response = await fetch(`/api/projects/${projectId}/activity?limit=200`); + if (!response.ok) throw new Error("Verlauf konnte nicht geladen werden."); + const payload = await response.json(); + setEntries(payload.activities as ActivityEntry[]); + setComplete(true); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setLoading(false); + } + } + + return ( +
+

+ + Verlauf +

+ + {entries.length === 0 ? ( +

+ Noch nichts passiert. Sobald du etwas änderst, steht es hier. +

+ ) : ( +
    + {entries.map((entry, index) => { + const meta = KINDS[entry.kind] ?? KINDS.PROJECT_UPDATED; + const Icon = meta.icon; + const letzter = index === entries.length - 1; + + return ( +
  1. + {/* Die Linie endet am letzten Eintrag, statt ins Leere zu laufen. */} + {!letzter ? ( + + ) : null} + + + + + +
    +

    {entry.summary}

    +

    + + · + {formatDateTime(entry.createdAt)} + {/* Ohne Benutzer war es der automatische Abgleich. */} + {entry.user ? null : ( + <> + · + + automatisch + + )} +

    +
    +
  2. + ); + })} +
+ )} + + {error ?

{error}

: null} + + {!complete ? ( + + ) : null} +
+ ); +} diff --git a/src/lib/activity.test.ts b/src/lib/activity.test.ts new file mode 100644 index 0000000..4ddde4f --- /dev/null +++ b/src/lib/activity.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { describeFields, describeStatus, shorten } from "./activity"; + +describe("describeFields", () => { + it("nennt ein einzelnes Feld beim Namen", () => { + expect(describeFields(["name"])).toBe("Name geändert"); + }); + + it("zaehlt zwei und drei Felder auf", () => { + expect(describeFields(["name", "tags"])).toBe("Name und Tags geändert"); + expect(describeFields(["name", "tags", "priority"])).toBe( + "Name, Tags und Priorität geändert", + ); + }); + + it("fasst ab vier Feldern zusammen, statt eine Litanei zu bilden", () => { + expect(describeFields(["name", "tags", "priority", "accent"])).toBe("4 Felder geändert"); + }); + + it("zaehlt zusammengehoerende Felder nur einmal", () => { + // repoOwner und repoName sind fuer den Leser eine einzige Aenderung. + expect(describeFields(["repoOwner", "repoName"])).toBe("Repository geändert"); + }); + + it("liefert null, wenn nichts Nennenswertes dabei war", () => { + // slug wird nur mitgeschrieben, wenn sich der Name aendert – der Name + // steht dann ohnehin schon in der Liste. + expect(describeFields([])).toBeNull(); + expect(describeFields(["slug", "updatedAt"])).toBeNull(); + }); +}); + +describe("describeStatus", () => { + it("uebersetzt beide Seiten in die Anzeigenamen", () => { + expect(describeStatus("IDEA", "IN_DEVELOPMENT")).toBe("Status: Idee → In Entwicklung"); + }); + + it("laesst unbekannte Werte stehen, statt sie zu verschlucken", () => { + expect(describeStatus("IDEA", "PHANTASIE")).toBe("Status: Idee → PHANTASIE"); + }); +}); + +describe("shorten", () => { + it("laesst Kurzes unangetastet", () => { + expect(shorten("Kurzer Titel")).toBe("Kurzer Titel"); + }); + + it("kuerzt auf die vorgegebene Laenge einschliesslich Auslassungszeichen", () => { + const gekuerzt = shorten("x".repeat(100), 20); + expect(gekuerzt).toHaveLength(20); + expect(gekuerzt.endsWith("…")).toBe(true); + }); + + it("macht aus Zeilenumbruechen eine Zeile", () => { + expect(shorten("Erste Zeile\n\n zweite Zeile ")).toBe("Erste Zeile zweite Zeile"); + }); +}); diff --git a/src/lib/activity.ts b/src/lib/activity.ts new file mode 100644 index 0000000..018de48 --- /dev/null +++ b/src/lib/activity.ts @@ -0,0 +1,135 @@ +import type { ActivityKind, Prisma } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { STATUS_META } from "@/lib/status"; + +/** + * Verlauf je Projekt: was ist wann passiert. + * + * Der Einzeiler wird beim Schreiben formuliert und nicht erst beim Anzeigen. + * Das ist Absicht: ein Eintrag soll auch dann noch lesbar sein, wenn es die + * Notiz oder die Aufgabe, um die es ging, laengst nicht mehr gibt. + */ + +/** Wie viele Eintraege je Projekt aufgehoben werden. */ +export const ACTIVITY_LIMIT = 200; + +type Entry = { + projectId: string; + /** null = die Anwendung selbst, etwa der Zeitgeber beim Abgleich. */ + userId?: string | null; + kind: ActivityKind; + summary: string; + meta?: Prisma.InputJsonValue; +}; + +/** + * Haelt einen Vorgang fest. Wirft nie: der Verlauf ist Beiwerk, und daran + * darf die eigentliche Aenderung nicht scheitern. + */ +export async function recordActivity(entry: Entry): Promise { + try { + await prisma.activity.create({ + data: { + projectId: entry.projectId, + userId: entry.userId ?? null, + kind: entry.kind, + summary: entry.summary, + meta: entry.meta, + }, + }); + } catch (error) { + console.error("[activity] konnte nicht geschrieben werden:", error); + } +} + +/** Mehrere Vorgaenge auf einmal – ein Rundgang statt vieler. */ +export async function recordActivities(entries: Entry[]): Promise { + if (entries.length === 0) return; + try { + await prisma.activity.createMany({ + data: entries.map((entry) => ({ + projectId: entry.projectId, + userId: entry.userId ?? null, + kind: entry.kind, + summary: entry.summary, + meta: entry.meta, + })), + }); + } catch (error) { + console.error("[activity] konnte nicht geschrieben werden:", error); + } +} + +// ── Formulierungshilfen ────────────────────────────────────── + +const FIELD_LABELS: Record = { + name: "Name", + summary: "Kurzbeschreibung", + description: "Beschreibung", + priority: "Priorität", + progress: "Fortschritt", + accent: "Farbe", + tags: "Tags", + favorite: "Favorit", + autoSync: "Automatischer Abgleich", + progressFromTasks: "Fortschritt aus Aufgaben", + connectionId: "Git-Verbindung", + repoUrl: "Repository", + repoOwner: "Repository", + repoName: "Repository", + defaultBranch: "Standard-Branch", +}; + +/** + * Aus geaenderten Feldnamen einen lesbaren Satz machen: "Name und Tags + * geändert". Doppelte Bezeichnungen fallen weg – wer repoOwner und repoName + * zusammen setzt, hat einmal das Repository geaendert, nicht zweimal. + */ +export function describeFields(fields: string[]): string | null { + const labels: string[] = []; + for (const field of fields) { + const label = FIELD_LABELS[field]; + if (label && !labels.includes(label)) labels.push(label); + } + if (labels.length === 0) return null; + if (labels.length === 1) return `${labels[0]} geändert`; + + // Ab vier Feldern wird die Aufzaehlung laenger als sie nuetzt. + if (labels.length > 3) return `${labels.length} Felder geändert`; + return `${labels.slice(0, -1).join(", ")} und ${labels.at(-1)} geändert`; +} + +/** "Idee → In Entwicklung" */ +export function describeStatus(from: string, to: string): string { + const label = (value: string) => + STATUS_META[value as keyof typeof STATUS_META]?.label ?? value; + return `Status: ${label(from)} → ${label(to)}`; +} + +/** Kuerzt lange Titel, damit ein Eintrag eine Zeile bleibt. */ +export function shorten(text: string, max = 70): string { + const clean = text.replace(/\s+/g, " ").trim(); + return clean.length <= max ? clean : `${clean.slice(0, max - 1)}…`; +} + +/** + * Stutzt den Verlauf auf die neuesten Eintraege je Projekt zurueck. Laeuft + * im taeglichen Durchgang des Zeitgebers mit – bei jedem Schreiben zu + * pruefen waere eine zusaetzliche Abfrage fuer nichts. + */ +export async function pruneActivities(): Promise { + const rows = await prisma.$queryRaw<{ count: bigint }[]>` + WITH nummeriert AS ( + SELECT "id", ROW_NUMBER() OVER ( + PARTITION BY "projectId" ORDER BY "createdAt" DESC, "id" DESC + ) AS rang + FROM "Activity" + ), geloescht AS ( + DELETE FROM "Activity" + WHERE "id" IN (SELECT "id" FROM nummeriert WHERE rang > ${ACTIVITY_LIMIT}) + RETURNING 1 + ) + SELECT COUNT(*)::bigint AS count FROM geloescht + `; + return Number(rows[0]?.count ?? 0); +} diff --git a/src/lib/export/format.ts b/src/lib/export/format.ts index 674f9f4..643d8a7 100644 --- a/src/lib/export/format.ts +++ b/src/lib/export/format.ts @@ -12,6 +12,9 @@ import { z } from "zod"; * ein Abgleich nach dem Import holt sie zurueck. * - keine Datenbank-IDs. Die gelten nur in der Instanz, aus der exportiert * wurde. + * - kein Verlauf. Er beschreibt, was in *dieser* Instanz passiert ist, und + * verwiese nach dem Import auf Konten, die es dort nicht gibt. Der Import + * setzt stattdessen einen eigenen ersten Eintrag. */ export const EXPORT_KIND = "nebula.projects"; diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 6153cdc..0135a08 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -64,5 +64,17 @@ export const projectDetailInclude = Prisma.validator()({ tasks: { orderBy: [{ status: "asc" }, { position: "asc" }], }, + // Erste Seite des Verlaufs; weitere holt die Anzeige bei Bedarf nach. + activities: { + orderBy: { createdAt: "desc" }, + take: 20, + select: { + id: true, + kind: true, + summary: true, + createdAt: true, + user: { select: { username: true, displayName: true } }, + }, + }, _count: { select: { notes: true, tasks: true } }, }); diff --git a/src/lib/sync/index.ts b/src/lib/sync/index.ts index 2842e33..b4d4fb5 100644 --- a/src/lib/sync/index.ts +++ b/src/lib/sync/index.ts @@ -1,4 +1,5 @@ import type { Project } from "@prisma/client"; +import { recordActivity, shorten } from "@/lib/activity"; import { prisma } from "@/lib/db"; import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git"; import { notifyAfterSync } from "@/lib/notify"; @@ -73,6 +74,15 @@ export async function syncProject(projectId: string): Promise { syncFailureCount: { increment: 1 }, }, }); + // Nur der Uebergang in den Fehlerzustand kommt in den Verlauf. Ein Repo + // mit abgelaufenem Token schlaegt sonst stuendlich neu auf. + if (!project.syncError) { + await recordActivity({ + projectId: project.id, + kind: "SYNC_FAILED", + summary: `Abgleich fehlgeschlagen: ${shorten(message, 120)}`, + }); + } await notifyAfterSync(project.id); return { ok: false, message }; } @@ -90,6 +100,40 @@ export function resolveRepoRef(project: Project) { return null; } +/** + * Haelt neue Commits im Verlauf fest. + * + * Der Abgleich laeuft im Zweifel stuendlich, ein Eintrag je Durchlauf waere + * also unbrauchbar. Vermerkt wird nur, wenn sich der letzte Commit tatsaechlich + * geaendert hat – und beim allerersten Abgleich gar nicht: dass ein Repo + * Commits hat, ist keine Neuigkeit. + */ +async function noteNewCommits( + projectId: string, + vorher: string | null, + data: RepoSnapshotData, +) { + const neuester = data.commits[0]?.sha ?? null; + if (!neuester || !vorher || neuester === vorher) return; + + // Wie viele Commits liegen zwischen altem und neuem Stand? Steht der alte + // nicht mehr in der Liste, waren es mehr als eine Seite – dann lieber ohne + // Zahl als mit einer falschen. + const index = data.commits.findIndex((commit) => commit.sha === vorher); + const anzahl = index > 0 ? index : null; + const wo = data.defaultBranch ? ` auf ${data.defaultBranch}` : ""; + + await recordActivity({ + projectId, + kind: "REPO_COMMITS", + summary: + anzahl === null + ? `Neue Commits${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}` + : `${anzahl} neue${anzahl === 1 ? "r" : ""} Commit${anzahl === 1 ? "" : "s"}${wo}: ${shorten(data.commits[0]?.message ?? "", 60)}`, + meta: { from: vorher, to: neuester, count: anzahl }, + }); +} + async function persistSnapshot(projectId: string, data: RepoSnapshotData) { const latestCommit = data.commits[0] ?? null; const latestRelease = @@ -133,6 +177,12 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) { fetchedAt: new Date(), }; + // Vor dem Ueberschreiben lesen – danach ist der alte Stand weg. + const vorher = await prisma.repoSnapshot.findUnique({ + where: { projectId }, + select: { lastCommitSha: true }, + }); + await prisma.$transaction([ prisma.repoSnapshot.upsert({ where: { projectId }, @@ -150,4 +200,6 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) { }, }), ]); + + await noteNewCommits(projectId, vorher?.lastCommitSha ?? null, data); } diff --git a/src/lib/sync/scheduler.ts b/src/lib/sync/scheduler.ts index 9e1cc61..3283cd4 100644 --- a/src/lib/sync/scheduler.ts +++ b/src/lib/sync/scheduler.ts @@ -1,4 +1,5 @@ import type { Prisma } from "@prisma/client"; +import { pruneActivities } from "@/lib/activity"; import { getAppSettings } from "@/lib/auth/settings"; import { prisma } from "@/lib/db"; import { runStaleCheck } from "@/lib/notify/stale"; @@ -76,19 +77,19 @@ async function tick() { } } - // Unabhaengig vom Abgleich: der Stillstand ergibt sich aus dem - // gespeicherten Stand, dafuer muss nichts geholt werden. - await maybeCheckStale(); + // Unabhaengig vom Abgleich: beides ergibt sich aus dem gespeicherten + // Stand, dafuer muss nichts geholt werden. + await dailyChores(); } catch (error) { console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error); } } /** - * Stillstandspruefung, hoechstens einmal am Tag. Laeuft nur, wenn ueberhaupt - * jemand darauf hoert – sonst waere es reine Datenbanklast. + * Was hoechstens einmal am Tag zu tun ist: den Verlauf stutzen und nach + * stillstehenden Projekten sehen. */ -async function maybeCheckStale() { +async function dailyChores() { const settings = await getAppSettings(); const last = settings.lastStaleCheckAt?.getTime() ?? 0; if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return; @@ -104,6 +105,17 @@ async function maybeCheckStale() { data: { lastStaleCheckAt: new Date() }, }); + // Der Verlauf waechst mit jeder Aenderung; einmal am Tag zurueckschneiden + // reicht, um ihn beschraenkt zu halten. + try { + const entfernt = await pruneActivities(); + if (entfernt > 0) console.log(`[nebula] Verlauf gestutzt: ${entfernt} Eintraege entfernt.`); + } catch (error) { + console.error("[nebula] Verlauf konnte nicht gestutzt werden:", error); + } + + // Die Stillstandspruefung laeuft nur, wenn jemand zuhoert – sonst waere sie + // reine Datenbanklast. if (zuhoerer === 0) return; const report = await runStaleCheck(Math.max(1, settings.staleAfterDays)); diff --git a/src/types/index.ts b/src/types/index.ts index 055dfcd..41f589d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,4 +1,5 @@ import type { + ActivityKind, GitProvider, Note, Project, @@ -43,11 +44,22 @@ export interface ProjectListItem extends Project { tasks: { id: string }[]; } +/** Ein Eintrag im Projektverlauf, so wie ihn die Anzeige braucht. */ +export interface ActivityEntry { + id: string; + kind: ActivityKind; + summary: string; + createdAt: Date | string; + /** null bedeutet: die Anwendung selbst, etwa der automatische Abgleich. */ + user: { username: string; displayName: string | null } | null; +} + export interface ProjectDetail extends Project { connection: ConnectionSafe | null; snapshot: RepoSnapshot | null; notes: Note[]; tasks: Task[]; + activities: ActivityEntry[]; _count: { notes: number; tasks: number }; } @@ -63,6 +75,7 @@ export interface RepoListEntry { } export type { + ActivityKind, BranchInfo, CiInfo, CommitInfo,