feat(git): mehrere Repositories je Projekt

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>
This commit is contained in:
2026-07-26 13:23:53 +02:00
co-authored by Claude Opus 5
parent 2bff6faf3d
commit a843f08211
29 changed files with 1207 additions and 268 deletions
+3 -1
View File
@@ -27,7 +27,9 @@ export default async function AdminPage() {
}),
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
prisma.project.count({
// Gezaehlt werden Repositories, nicht Projekte: sie sind die Einheit, in
// der abgeglichen und begrenzt wird.
prisma.repository.count({
where: {
autoSync: true,
OR: [
+1 -1
View File
@@ -14,7 +14,7 @@ type Params = { params: Promise<{ id: string }> };
const safeSelect = {
...connectionSafeSelect,
_count: { select: { projects: true } },
_count: { select: { repositories: true } },
};
export async function PATCH(request: Request, { params }: Params) {
+1 -1
View File
@@ -14,7 +14,7 @@ export const dynamic = "force-dynamic";
/** Verbindungen ohne Token-Chiffrat – das verlaesst den Server nie. */
const safeSelect = {
...connectionSafeSelect,
_count: { select: { projects: true } },
_count: { select: { repositories: true } },
};
export async function GET() {
+11 -5
View File
@@ -1,7 +1,7 @@
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { exportFileName } from "@/lib/export/format";
import { buildExportFile, toMarkdown } from "@/lib/export/project";
import { buildExportFile, exportInclude, toMarkdown } from "@/lib/export/project";
import { jsonError } from "@/lib/security/request";
export const dynamic = "force-dynamic";
@@ -24,10 +24,16 @@ export async function GET(request: Request, { params }: Params) {
const project = await prisma.project.findFirst({
where: { id, ownerId: auth.user.id },
include: {
notes: { orderBy: [{ pinned: "desc" }, { createdAt: "asc" }] },
tasks: { orderBy: [{ status: "asc" }, { position: "asc" }] },
connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true,
...exportInclude,
// Fuer die Markdown-Fassung zusaetzlich der gespeicherte Stand, damit
// die Datei auch ohne Netz vollstaendig ist.
repositories: {
include: {
connection: { select: { name: true, provider: true, baseUrl: true } },
snapshot: true,
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
});
@@ -0,0 +1,122 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import {
completeRepoInput,
namesRepository,
nextRepoPosition,
repoTitle,
} from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { ownedProject } from "@/lib/tasks";
import { formatZodError, repositoryCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Hoechstzahl je Projekt. Wer mehr braucht, hat mehrere Vorhaben. */
const MAX_REPOS = 20;
const include = {
connection: { select: connectionSafeSelect },
snapshot: true,
} as const;
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 repositories = await prisma.repository.findMany({
where: { projectId: id },
include,
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
});
return NextResponse.json({ repositories });
}
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 = repositoryCreateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const eingabe = completeRepoInput(parsed.data);
if (!namesRepository(eingabe)) {
return jsonError("Bitte eine Repo-URL oder Owner und Name angeben.", 422);
}
// Eine fremde Verbindung darf nicht untergeschoben werden.
if (eingabe.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: eingabe.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const vorhanden = await prisma.repository.count({ where: { projectId: id } });
if (vorhanden >= MAX_REPOS) {
return jsonError(`Mehr als ${MAX_REPOS} Repositories je Projekt sind nicht vorgesehen.`, 409);
}
// Dasselbe Repo zweimal am selben Projekt ist immer ein Versehen.
if (eingabe.repoOwner && eingabe.repoName) {
const doppelt = await prisma.repository.findFirst({
where: { projectId: id, repoOwner: eingabe.repoOwner, repoName: eingabe.repoName },
select: { id: true },
});
if (doppelt) {
return jsonError(
`${eingabe.repoOwner}/${eingabe.repoName} ist mit diesem Projekt bereits verknuepft.`,
409,
);
}
}
const repository = await prisma.repository.create({
data: {
projectId: id,
label: eingabe.label ?? null,
repoUrl: eingabe.repoUrl ?? null,
repoOwner: eingabe.repoOwner ?? null,
repoName: eingabe.repoName ?? null,
connectionId: eingabe.connectionId ?? null,
autoSync: eingabe.autoSync ?? true,
position: await nextRepoPosition(id),
},
include,
});
await recordActivity({
projectId: id,
userId: auth.user.id,
kind: "REPO_LINKED",
summary: `Repository verknüpft: ${shorten(repoTitle(repository))}`,
meta: { repositoryId: repository.id },
});
return NextResponse.json({ repository }, { status: 201 });
}
+25 -26
View File
@@ -3,8 +3,8 @@ 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";
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";
@@ -77,30 +77,26 @@ export async function PATCH(request: Request, { params }: Params) {
"accent",
"tags",
"favorite",
"autoSync",
"progressFromTasks",
"connectionId",
] as const) {
if (data[key] !== undefined) update[key] = data[key];
}
if (data.repoUrl !== undefined) {
update.repoUrl = data.repoUrl;
const parsedUrl = data.repoUrl ? parseRepoUrl(data.repoUrl) : null;
if (parsedUrl) {
update.repoOwner = data.repoOwner ?? parsedUrl.owner;
update.repoName = data.repoName ?? parsedUrl.name;
} else if (!data.repoUrl) {
update.repoOwner = null;
update.repoName = null;
}
}
if (data.repoOwner !== undefined) update.repoOwner = data.repoOwner;
if (data.repoName !== undefined) update.repoName = data.repoName;
await prisma.project.update({ where: { id }, data: update });
await noteChanges(id, auth.user.id, existing, 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.
@@ -125,12 +121,11 @@ export async function PATCH(request: Request, { params }: Params) {
async function noteChanges(
projectId: string,
userId: string,
before: { status: ProjectStatus; repoUrl: string | null },
before: { status: ProjectStatus },
update: Record<string, unknown>,
repo: RepoInput | null,
) {
const rest = Object.keys(update).filter(
(key) => key !== "status" && key !== "slug" && !key.startsWith("repo"),
);
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) {
@@ -144,16 +139,20 @@ async function noteChanges(
});
}
if (update.repoUrl !== undefined && update.repoUrl !== before.repoUrl) {
const url = update.repoUrl as string | null;
// 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: url ? `Repository verknüpft: ${shorten(url)}` : "Repository-Verknüpfung entfernt",
meta: { repoUrl: url },
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) {
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { recordActivity } from "@/lib/activity";
import { repoTitle } from "@/lib/repositories";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
@@ -15,6 +16,8 @@ type Params = { params: Promise<{ id: string }> };
const schema = z.object({
/** Leer heisst: alle offenen Issues aus dem Snapshot. */
numbers: z.array(z.number().int()).max(200).optional(),
/** Aus welchem Repository. Leer heisst: dem fuehrenden. */
repositoryId: z.string().optional(),
});
/**
@@ -49,7 +52,23 @@ export async function POST(request: Request, { params }: Params) {
);
}
const snapshot = await prisma.repoSnapshot.findUnique({ where: { projectId: id } });
// Die Besitzerbedingung laeuft ueber das Projekt, dessen Besitz eben
// geprueft wurde – eine fremde Repository-ID trifft hier also nichts.
const repository = await prisma.repository.findFirst({
where: { projectId: id, ...(parsed.data.repositoryId ? { id: parsed.data.repositoryId } : {}) },
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
include: { snapshot: true },
});
if (!repository) {
// 404 nur, wenn nach einem bestimmten gefragt wurde. Wer nichts angibt,
// hat schlicht noch keines verknuepft – und das ist kein "nicht gefunden".
return parsed.data.repositoryId
? jsonError("Repository nicht gefunden", 404)
: jsonError("Kein Repository verknuepft. Bitte zuerst eines hinterlegen.", 409);
}
const snapshot = repository.snapshot;
if (!snapshot) {
return jsonError(
"Noch kein Repo-Stand vorhanden. Bitte zuerst synchronisieren.",
@@ -68,7 +87,11 @@ export async function POST(request: Request, { params }: Params) {
}
const vorhanden = await prisma.task.findMany({
where: { projectId: id, sourceIssue: { in: auswahl.map((issue) => issue.number) } },
where: {
projectId: id,
sourceRepositoryId: repository.id,
sourceIssue: { in: auswahl.map((issue) => issue.number) },
},
select: { sourceIssue: true },
});
const bekannt = new Set(vorhanden.map((task) => task.sourceIssue));
@@ -90,6 +113,7 @@ export async function POST(request: Request, { params }: Params) {
labels: issue.labels.slice(0, 8),
sourceIssue: issue.number,
sourceUrl: issue.url,
sourceRepositoryId: repository.id,
},
}),
);
@@ -106,9 +130,12 @@ export async function POST(request: Request, { params }: Params) {
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) },
? `1 Issue aus ${repoTitle(repository)} als Aufgabe übernommen`
: `${angelegt.length} Issues aus ${repoTitle(repository)} als Aufgaben übernommen`,
meta: {
repositoryId: repository.id,
numbers: angelegt.map((task) => task.sourceIssue),
},
});
}
+39 -15
View File
@@ -3,7 +3,11 @@ 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";
import {
exportFileSchema,
repositoriesOf,
type ExportedRepository,
} from "@/lib/export/format";
import { uniqueSlug } from "@/lib/projects";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import {
@@ -85,7 +89,7 @@ export async function POST(request: Request) {
select: { id: true, name: true, provider: true, baseUrl: true },
});
function matchConnection(described: ExportedProject["connection"]) {
function matchConnection(described: ExportedRepository["connection"]) {
if (!described) return null;
const byName = connections.find(
(candidate) => candidate.name.toLowerCase() === described.name.toLowerCase(),
@@ -105,12 +109,26 @@ export async function POST(request: Request) {
for (const incoming of file.projects) {
const slug = incoming.slug || slugify(incoming.name) || "projekt";
const connection = matchConnection(incoming.connection);
const connectionState: ItemResult["connection"] = incoming.connection
? connection
? "matched"
: "missing"
: "none";
// Version 1 kannte ein Repository je Projekt und schrieb es flach; seit
// Version 2 steht eine Liste da. repositoriesOf glaettet den Unterschied.
const eingehendeRepos = repositoriesOf(incoming);
const repos = eingehendeRepos.map((repo, index) => ({
label: repo.label ?? null,
repoUrl: repo.repoUrl ?? null,
repoOwner: repo.repoOwner ?? null,
repoName: repo.repoName ?? null,
autoSync: repo.autoSync ?? true,
connectionId: matchConnection(repo.connection ?? null)?.id ?? null,
position: index,
}));
// Fuer die Rueckmeldung genuegt, ob ueberhaupt eine beschriebene
// Verbindung fehlt – welche, steht in der Datei.
const beschrieben = eingehendeRepos.filter((repo) => repo.connection);
const fehlend = beschrieben.filter((repo) => !matchConnection(repo.connection ?? null));
const connectionState: ItemResult["connection"] =
beschrieben.length === 0 ? "none" : fehlend.length > 0 ? "missing" : "matched";
const existing = await prisma.project.findFirst({
where: { slug, ownerId: auth.user.id },
@@ -123,7 +141,7 @@ export async function POST(request: Request) {
action: "create",
notes: incoming.notes.length,
connection: connectionState,
connectionName: incoming.connection?.name,
connectionName: (fehlend[0] ?? beschrieben[0])?.connection?.name,
};
if (existing && strategy === "skip") {
@@ -155,11 +173,6 @@ export async function POST(request: Request) {
accent: incoming.accent,
tags: incoming.tags,
favorite: incoming.favorite,
autoSync: incoming.autoSync,
repoUrl: incoming.repoUrl ?? null,
repoOwner: incoming.repoOwner ?? null,
repoName: incoming.repoName ?? null,
connectionId: connection?.id ?? null,
progressFromTasks: incoming.progressFromTasks,
};
@@ -195,9 +208,19 @@ export async function POST(request: Request) {
await prisma.$transaction([
prisma.note.deleteMany({ where: { projectId: existing.id } }),
prisma.task.deleteMany({ where: { projectId: existing.id } }),
// Wie Notizen und Aufgaben: austauschen statt zusammenfuehren. Der
// gesammelte Repo-Stand geht dabei mit, ist aber beim naechsten
// Abgleich zurueck.
prisma.repository.deleteMany({ where: { projectId: existing.id } }),
prisma.project.update({
where: { id: existing.id },
data: { ...data, slug, notes: { create: notes }, tasks: { create: tasks } },
data: {
...data,
slug,
notes: { create: notes },
tasks: { create: tasks },
repositories: { create: repos },
},
}),
]);
results.push({ ...base, action: "overwrite" });
@@ -210,6 +233,7 @@ export async function POST(request: Request) {
slug: finalSlug,
notes: { create: notes },
tasks: { create: tasks },
repositories: { create: repos },
},
});
// Der Verlauf wird nicht mitexportiert – der erste Eintrag hier haelt
+16 -18
View File
@@ -2,8 +2,8 @@ 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 { applyLeadingRepo } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { statusOf } from "@/lib/status";
@@ -74,18 +74,7 @@ export async function POST(request: Request) {
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
// Owner/Name aus der URL ableiten, falls nicht explizit angegeben.
let repoOwner = data.repoOwner ?? null;
let repoName = data.repoName ?? null;
if (data.repoUrl && (!repoOwner || !repoName)) {
const parsedUrl = parseRepoUrl(data.repoUrl);
if (parsedUrl) {
repoOwner = repoOwner ?? parsedUrl.owner;
repoName = repoName ?? parsedUrl.name;
}
}
const project = await prisma.project.create({
const angelegt = await prisma.project.create({
data: {
ownerId: auth.user.id,
name: data.name,
@@ -98,12 +87,21 @@ export async function POST(request: Request) {
accent: data.accent,
tags: data.tags,
favorite: data.favorite,
autoSync: data.autoSync,
repoUrl: data.repoUrl ?? null,
repoOwner,
repoName,
connectionId: data.connectionId ?? null,
},
select: { id: true },
});
// Die Git-Angaben werden zum fuehrenden Repository – falls welche kamen.
await applyLeadingRepo(angelegt.id, {
repoUrl: data.repoUrl,
repoOwner: data.repoOwner,
repoName: data.repoName,
connectionId: data.connectionId,
autoSync: data.autoSync,
});
const project = await prisma.project.findUniqueOrThrow({
where: { id: angelegt.id },
include: projectListInclude,
});
+93
View File
@@ -0,0 +1,93 @@
import { NextResponse } from "next/server";
import { recordActivity, shorten } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import { completeRepoInput, ownedRepository, repoTitle } from "@/lib/repositories";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, repositoryUpdateSchema } 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 ownedRepository(id, auth.user.id);
if (!existing) return jsonError("Repository nicht gefunden", 404);
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = repositoryUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const eingabe = completeRepoInput(parsed.data);
if (eingabe.connectionId) {
const connection = await prisma.gitConnection.findFirst({
where: { id: eingabe.connectionId, ownerId: auth.user.id },
select: { id: true },
});
if (!connection) return jsonError("Git-Verbindung nicht gefunden.", 404);
}
const data: Record<string, unknown> = {};
for (const key of ["label", "repoUrl", "repoOwner", "repoName", "connectionId", "autoSync"] as const) {
if (eingabe[key] !== undefined) data[key] = eingabe[key];
}
if (parsed.data.position !== undefined) data.position = parsed.data.position;
// Zeigt das Repo woandershin, taugt der gesammelte Stand nicht mehr. Ihn
// stehen zu lassen waere schlimmer als ihn zu verlieren: er saehe echt aus.
const zieltWoandershin =
(eingabe.repoOwner !== undefined && eingabe.repoOwner !== existing.repoOwner) ||
(eingabe.repoName !== undefined && eingabe.repoName !== existing.repoName);
if (zieltWoandershin) {
data.lastSyncedAt = null;
data.syncError = null;
data.syncFailureCount = 0;
data.notifiedSyncError = null;
data.notifiedCiState = null;
data.notifiedRelease = null;
await prisma.repoSnapshot.deleteMany({ where: { repositoryId: id } });
}
const repository = await prisma.repository.update({
where: { id },
data,
include: { connection: { select: connectionSafeSelect }, snapshot: true },
});
return NextResponse.json({ repository });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const { id } = await params;
// Vor dem Loeschen lesen: danach ist nicht mehr feststellbar, worum es ging.
const vorher = await ownedRepository(id, auth.user.id);
if (!vorher) return jsonError("Repository nicht gefunden", 404);
await prisma.repository.delete({ where: { id } });
await recordActivity({
projectId: vorher.projectId,
userId: auth.user.id,
kind: "REPO_LINKED",
summary: `Repository entfernt: ${shorten(repoTitle(vorher))}`,
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { connectionSafeSelect } from "@/lib/projects";
import { ownedRepository } from "@/lib/repositories";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
import { syncRepository } from "@/lib/sync";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Gleicht ein einzelnes Repository ab. */
export async function POST(request: Request, { params }: Params) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
// Dieser Endpunkt loest ausgehende Verbindungen aus – hier wird enger
// begrenzt als bei reinen Datenbankzugriffen.
const limited = rateLimit(
`outbound:${auth.user.id}:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const { id } = await params;
if (!(await ownedRepository(id, auth.user.id))) {
return jsonError("Repository nicht gefunden", 404);
}
const result = await syncRepository(id);
const repository = await prisma.repository.findUnique({
where: { id },
include: { connection: { select: connectionSafeSelect }, snapshot: true },
});
return NextResponse.json({ ...result, repository }, { status: result.ok ? 200 : 502 });
}
+1 -1
View File
@@ -17,7 +17,7 @@ export default async function ConnectionsPage() {
const connections = await prisma.gitConnection.findMany({
where: { ownerId: user.id },
select: { ...connectionSafeSelect, _count: { select: { projects: true } } },
select: { ...connectionSafeSelect, _count: { select: { repositories: true } } },
orderBy: { name: "asc" },
});
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function DashboardPage() {
const ideas = active.filter((project) => project.status === "IDEA").length;
const inDev = active.filter((project) => project.status === "IN_DEVELOPMENT").length;
const done = active.filter((project) => project.status === "DONE").length;
const linked = active.filter((project) => Boolean(project.repoOwner && project.repoName)).length;
const linked = active.filter((project) => project.repositories.length > 0).length;
const averageProgress =
active.length > 0