Ein Vorhaben besteht oft aus Frontend und Backend - zwei Repositories, aber eine Karte. Die Git-Felder wandern deshalb vom Projekt in eine eigene Tabelle, der Snapshot haengt jetzt am Repository. Das erste Repository ist das fuehrende. Wo nur eines dargestellt werden kann - Karte, Kanban -, ist es dieses. Ein Abgleichsfehler an *irgendeinem* erscheint aber trotzdem auf der Karte, sonst bliebe er unbemerkt. Der Merkstand fuer Benachrichtigungen liegt nun am Repository. Am Projekt verdeckte ein rotes Frontend die rote Pipeline des Backends. Stillstand bleibt dagegen eine Aussage ueber das Vorhaben und bemisst sich am juengsten Commit ueber alle Repos: an einem Projekt wird gearbeitet, sobald an einem seiner Teile gearbeitet wird. Der Zeitgeber zaehlt und begrenzt jetzt Repositories statt Projekte. Sonst zoege ein Projekt mit fuenf Repos fuenfmal so viele Anfragen nach sich, wie sein Platz im Kontingent hergibt. "Alle abgleichen" laeuft nacheinander - ein Schwung gleichzeitiger Anfragen an denselben Anbieter ist genau das, worauf Rate-Limits reagieren. Die Issue-Nummer allein reicht als Dublettenschutz nicht mehr: Frontend und Backend haben beide ein Issue 5, und das sind zwei Aufgaben. Die Herkunft gehoert deshalb mit in die Eindeutigkeit. In Postgres nachgewiesen: dieselbe Nummer in zwei Repos geht durch, zweimal im selben Repo nicht, mehrere Aufgaben ohne Herkunft bleiben erlaubt. Die Projekt-API nimmt die Git-Felder weiterhin an und setzt damit das fuehrende Repository - so kommt man mit einem Aufruf zu Projekt und Repo, und der Import muss von der neuen Tabelle nichts wissen. Die Exportdatei zaehlt auf 2 hoch und traegt eine Liste. Version 1 bleibt lesbar: dort stand genau ein Repository flach am Projekt. Die Datenmigration ist von Hand geschrieben - migrate diff kennt nur den Zielzustand und haette die Spalten samt Snapshots ersatzlos geloescht. Gegen eine Kopie mit echten Daten geprueft: Verknuepfung, Verbindung, Fehlerzaehler und Snapshot kommen vollstaendig mit, Projekte ohne Repo bekommen keine leere Zeile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
5.8 KiB
TypeScript
185 lines
5.8 KiB
TypeScript
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 { projectDetailInclude, projectListInclude, uniqueSlug } from "@/lib/projects";
|
||
import { applyLeadingRepo, type RepoInput } from "@/lib/repositories";
|
||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||
import { refreshDerivedProgress } from "@/lib/tasks";
|
||
import { formatZodError, projectUpdateSchema } 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;
|
||
const project = await prisma.project.findFirst({
|
||
where: { id, ownerId: auth.user.id },
|
||
include: projectDetailInclude,
|
||
});
|
||
|
||
// Fremde Projekte werden wie nicht vorhandene behandelt – die Antwort
|
||
// verraet also nicht, dass es die ID gibt.
|
||
if (!project) return jsonError("Projekt nicht gefunden", 404);
|
||
|
||
return NextResponse.json({ project });
|
||
}
|
||
|
||
export async function PATCH(request: Request, { params }: Params) {
|
||
const auth = await requireUserApi();
|
||
if (!auth.ok) return auth.response;
|
||
|
||
const { id } = await params;
|
||
|
||
const body = await readJsonBody(request);
|
||
if (!body.ok) return body.response;
|
||
|
||
const parsed = projectUpdateSchema.safeParse(body.data);
|
||
if (!parsed.success) {
|
||
return NextResponse.json(
|
||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||
{ status: 422 },
|
||
);
|
||
}
|
||
|
||
const existing = await prisma.project.findFirst({
|
||
where: { id, ownerId: auth.user.id },
|
||
});
|
||
if (!existing) return jsonError("Projekt nicht gefunden", 404);
|
||
|
||
const data = parsed.data;
|
||
|
||
if (data.connectionId) {
|
||
const connection = await prisma.gitConnection.findFirst({
|
||
where: { id: data.connectionId, ownerId: auth.user.id },
|
||
select: { id: true },
|
||
});
|
||
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
|
||
}
|
||
|
||
const update: Record<string, unknown> = {};
|
||
|
||
if (data.name !== undefined && data.name !== existing.name) {
|
||
update.name = data.name;
|
||
update.slug = await uniqueSlug(data.name, auth.user.id, id);
|
||
}
|
||
for (const key of [
|
||
"summary",
|
||
"description",
|
||
"status",
|
||
"priority",
|
||
"progress",
|
||
"accent",
|
||
"tags",
|
||
"favorite",
|
||
"progressFromTasks",
|
||
] as const) {
|
||
if (data[key] !== undefined) update[key] = data[key];
|
||
}
|
||
|
||
await prisma.project.update({ where: { id }, data: update });
|
||
|
||
// Die Git-Felder gehen an das fuehrende Repository. Sie hier weiterhin
|
||
// anzunehmen erspart der Oberflaeche zwei Aufrufe fuer einen Speichervorgang.
|
||
const repoFelder = {
|
||
repoUrl: data.repoUrl,
|
||
repoOwner: data.repoOwner,
|
||
repoName: data.repoName,
|
||
connectionId: data.connectionId,
|
||
autoSync: data.autoSync,
|
||
};
|
||
const repoBeruehrt = Object.values(repoFelder).some((value) => value !== undefined);
|
||
if (repoBeruehrt) await applyLeadingRepo(id, repoFelder);
|
||
|
||
await noteChanges(id, auth.user.id, existing, update, repoBeruehrt ? repoFelder : null);
|
||
|
||
// Wird die Ableitung eingeschaltet, soll der Wert sofort stimmen und nicht
|
||
// erst beim naechsten Aufgabenwechsel.
|
||
if (data.progressFromTasks === true) {
|
||
await refreshDerivedProgress(id);
|
||
}
|
||
|
||
const project = await prisma.project.findUniqueOrThrow({
|
||
where: { id },
|
||
include: projectListInclude,
|
||
});
|
||
|
||
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 },
|
||
update: Record<string, unknown>,
|
||
repo: RepoInput | null,
|
||
) {
|
||
const rest = Object.keys(update).filter((key) => key !== "status" && key !== "slug");
|
||
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 },
|
||
});
|
||
}
|
||
|
||
// Nur die Verknuepfung selbst ist eine Nachricht wert. Ob der automatische
|
||
// Abgleich an oder aus ist, gehoert zu den uebrigen Feldern.
|
||
if (repo && (repo.repoUrl !== undefined || repo.repoName !== undefined)) {
|
||
const ziel = repo.repoUrl ?? (repo.repoOwner ? `${repo.repoOwner}/${repo.repoName}` : null);
|
||
entries.push({
|
||
projectId,
|
||
userId,
|
||
kind: "REPO_LINKED",
|
||
summary: ziel ? `Repository verknüpft: ${shorten(ziel)}` : "Repository-Angaben geleert",
|
||
meta: { repoUrl: repo.repoUrl ?? null },
|
||
});
|
||
}
|
||
if (repo?.autoSync !== undefined) rest.push("autoSync");
|
||
if (repo?.connectionId !== undefined) rest.push("connectionId");
|
||
|
||
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;
|
||
|
||
const { id } = await params;
|
||
|
||
// deleteMany mit Besitzerbedingung: trifft fremde Projekte gar nicht erst.
|
||
const result = await prisma.project.deleteMany({
|
||
where: { id, ownerId: auth.user.id },
|
||
});
|
||
if (result.count === 0) return jsonError("Projekt nicht gefunden", 404);
|
||
|
||
return NextResponse.json({ ok: true });
|
||
}
|