diff --git a/prisma/migrations/20260726160000_stale_projects/migration.sql b/prisma/migrations/20260726160000_stale_projects/migration.sql new file mode 100644 index 0000000..56b0eb0 --- /dev/null +++ b/prisma/migrations/20260726160000_stale_projects/migration.sql @@ -0,0 +1,9 @@ +-- AlterEnum +ALTER TYPE "NotificationEvent" ADD VALUE 'PROJECT_STALE'; + +-- AlterTable +ALTER TABLE "AppSettings" ADD COLUMN "lastStaleCheckAt" TIMESTAMP(3), +ADD COLUMN "staleAfterDays" INTEGER NOT NULL DEFAULT 60; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "notifiedStaleAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d27bfea..9841af8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,6 +48,8 @@ enum NotificationEvent { CI_FAILED /// Im Repository ist ein neuer Tag oder ein neues Release aufgetaucht. NEW_RELEASE + /// Seit laengerem kein Commit mehr – der sanfte Stups. + PROJECT_STALE } /// Spalten des Aufgabenbretts innerhalb eines Projekts. @@ -181,6 +183,12 @@ model AppSettings { /// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms } lastAutoSyncReport Json? + // ── Stillstand ───────────────────────────────────────────── + /// Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt. + staleAfterDays Int @default(60) + /// Die Pruefung laeuft hoechstens einmal am Tag. + lastStaleCheckAt DateTime? + updatedAt DateTime @updatedAt } @@ -273,6 +281,9 @@ model Project { notifiedSyncError String? notifiedCiState String? notifiedRelease String? + /// Wann zuletzt wegen Stillstands gemeldet wurde. Erneut gemeldet wird + /// erst, wenn danach wieder ein Commit kam und es dann wieder still wurde. + notifiedStaleAt DateTime? notes Note[] tasks Task[] diff --git a/src/lib/notify/stale.test.ts b/src/lib/notify/stale.test.ts new file mode 100644 index 0000000..088abcb --- /dev/null +++ b/src/lib/notify/stale.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { daysSince, isStaleDue } from "./stale"; + +/** + * Die Entscheidung, wann ein Projekt als eingeschlafen gilt – und vor allem, + * wann es *nicht* erneut gemeldet wird. + */ + +const TAG = 86_400_000; +const jetzt = new Date("2026-07-26T12:00:00Z"); +const vorTagen = (tage: number) => new Date(jetzt.getTime() - tage * TAG); +/** Schwelle: 60 Tage. */ +const cutoff = vorTagen(60); + +describe("isStaleDue", () => { + it("meldet ein Projekt, dessen letzter Commit laenger zurueckliegt", () => { + expect(isStaleDue({ notifiedStaleAt: null }, vorTagen(90), cutoff)).toBe(true); + }); + + it("meldet nichts innerhalb der Frist", () => { + expect(isStaleDue({ notifiedStaleAt: null }, vorTagen(30), cutoff)).toBe(false); + }); + + it("meldet nichts genau an der Schwelle", () => { + expect(isStaleDue({ notifiedStaleAt: null }, cutoff, cutoff)).toBe(false); + }); + + it("meldet nichts ohne bekannten letzten Commit", () => { + // Ein Projekt ohne Repository oder ohne Snapshot laesst sich nicht + // beurteilen – lieber schweigen als raten. + expect(isStaleDue({ notifiedStaleAt: null }, null, cutoff)).toBe(false); + }); + + it("meldet dasselbe Projekt kein zweites Mal", () => { + expect(isStaleDue({ notifiedStaleAt: vorTagen(5) }, vorTagen(90), cutoff)).toBe(false); + }); + + it("meldet erneut, wenn zwischendurch gearbeitet wurde und es wieder still ist", () => { + // Gemeldet vor 200 Tagen, danach ein Commit vor 90 Tagen, seitdem Ruhe. + expect(isStaleDue({ notifiedStaleAt: vorTagen(200) }, vorTagen(90), cutoff)).toBe(true); + }); + + it("meldet nicht erneut, wenn der Commit vor der letzten Meldung lag", () => { + expect(isStaleDue({ notifiedStaleAt: vorTagen(10) }, vorTagen(90), cutoff)).toBe(false); + }); +}); + +describe("daysSince", () => { + it("zaehlt volle Tage", () => { + expect(daysSince(vorTagen(60), jetzt)).toBe(60); + expect(daysSince(vorTagen(1), jetzt)).toBe(1); + }); + + it("rundet angebrochene Tage ab", () => { + expect(daysSince(new Date(jetzt.getTime() - 1.9 * TAG), jetzt)).toBe(1); + }); + + it("liefert null fuer soeben", () => { + expect(daysSince(jetzt, jetzt)).toBe(0); + }); +}); diff --git a/src/lib/notify/stale.ts b/src/lib/notify/stale.ts new file mode 100644 index 0000000..1caf938 --- /dev/null +++ b/src/lib/notify/stale.ts @@ -0,0 +1,128 @@ +import type { Project, RepoSnapshot } from "@prisma/client"; +import { APP_URL } from "@/lib/config"; +import { prisma } from "@/lib/db"; +import { statusOf } from "@/lib/status"; +import { deliver } from "./channels"; +import { EVENT_META, type NotificationMessage } from "./types"; + +/** + * Meldet Projekte, in denen laenger nichts passiert ist. + * + * Anders als die uebrigen Anlaesse haengt dieser an keinem Ereignis – es + * passiert ja gerade nichts. Deshalb ein eigener Durchlauf, hoechstens + * einmal am Tag. + * + * Massstab ist ausschliesslich der letzte Commit im verknuepften + * Repository. Das Aenderungsdatum des Projekts taugt nicht: jeder Abgleich + * fasst es an und wuerde den Stillstand immer wieder zuruecksetzen. + * Projekte ohne Repository bleiben folglich aussen vor. + */ + +export interface StaleReport { + at: string; + checked: number; + stale: number; + notified: number; + thresholdDays: number; +} + +/** Entscheidet, ob ein Projekt jetzt zu melden ist. */ +export function isStaleDue( + project: Pick, + lastCommitAt: Date | null, + cutoff: Date, +): boolean { + // Ohne bekannten letzten Commit gibt es nichts zu beurteilen. + if (!lastCommitAt) return false; + // Noch innerhalb der Frist. + if (lastCommitAt >= cutoff) return false; + // Noch nie gemeldet: jetzt. + if (!project.notifiedStaleAt) return true; + // Schon gemeldet – erneut nur, wenn danach wieder ein Commit kam und es + // seitdem wieder still geworden ist. + return lastCommitAt > project.notifiedStaleAt; +} + +/** Wie viele volle Tage seit dem letzten Commit vergangen sind. */ +export function daysSince(date: Date, now: Date = new Date()): number { + return Math.floor((now.getTime() - date.getTime()) / 86_400_000); +} + +function message( + project: Project, + snapshot: RepoSnapshot | null, + tage: number, +): NotificationMessage { + return { + event: "PROJECT_STALE", + title: `${EVENT_META.PROJECT_STALE.emoji} ${project.name}: seit ${tage} Tagen kein Commit`, + body: + `Der letzte Commit liegt ${tage} Tage zurueck. ` + + `Status: ${statusOf(project.status).label}. Vielleicht abschliessen oder archivieren?`, + priority: "normal", + url: snapshot?.htmlUrl ?? `${APP_URL}/projects/${project.id}`, + project: { id: project.id, name: project.name, slug: project.slug }, + at: new Date().toISOString(), + }; +} + +/** + * Ein Durchlauf. Meldet je Projekt hoechstens einmal und schreibt den + * Merkstand fort – auch dann, wenn niemand zuhoert. + */ +export async function runStaleCheck(thresholdDays: number): Promise { + const now = new Date(); + const cutoff = new Date(now.getTime() - thresholdDays * 86_400_000); + + const kandidaten = await prisma.project.findMany({ + where: { + // Fertige und archivierte Projekte duerfen ruhen. + status: { notIn: ["DONE", "ARCHIVED"] }, + ownerId: { not: null }, + snapshot: { isNot: null }, + }, + include: { snapshot: true }, + }); + + const faellig = kandidaten.filter((project) => + isStaleDue(project, project.snapshot?.lastCommitAt ?? null, cutoff), + ); + + let zugestellt = 0; + + for (const project of faellig) { + const lastCommitAt = project.snapshot?.lastCommitAt; + if (!lastCommitAt || !project.ownerId) continue; + + const ziele = await prisma.notificationTarget.findMany({ + where: { userId: project.ownerId, isActive: true, events: { has: "PROJECT_STALE" } }, + }); + + for (const ziel of ziele) { + const ergebnis = await deliver( + ziel, + message(project, project.snapshot, daysSince(lastCommitAt, now)), + ); + zugestellt += 1; + await prisma.notificationTarget.update({ + where: { id: ziel.id }, + data: { lastSentAt: now, lastOk: ergebnis.ok, lastMessage: ergebnis.message }, + }); + } + + // Auch ohne Ziel vermerken, sonst kaeme beim ersten eingerichteten Ziel + // die gesamte Vergangenheit hinterher. + await prisma.project.update({ + where: { id: project.id }, + data: { notifiedStaleAt: now }, + }); + } + + return { + at: now.toISOString(), + checked: kandidaten.length, + stale: faellig.length, + notified: zugestellt, + thresholdDays, + }; +} diff --git a/src/lib/notify/types.ts b/src/lib/notify/types.ts index 71e7b03..0c76391 100644 --- a/src/lib/notify/types.ts +++ b/src/lib/notify/types.ts @@ -5,6 +5,7 @@ export const NOTIFICATION_EVENTS: NotificationEvent[] = [ "SYNC_FAILED", "CI_FAILED", "NEW_RELEASE", + "PROJECT_STALE", ]; export const EVENT_META: Record< @@ -26,6 +27,11 @@ export const EVENT_META: Record< description: "Im Repository ist ein neuer Tag oder ein Release aufgetaucht.", emoji: "🏷️", }, + PROJECT_STALE: { + label: "Projekt steht still", + description: "Seit laengerem kein Commit mehr. Die Schwelle setzt ein Administrator.", + emoji: "🕸️", + }, }; /** Eine fertig formulierte Nachricht, unabhaengig vom Zustellweg. */ diff --git a/src/lib/sync/scheduler.ts b/src/lib/sync/scheduler.ts index fada231..9e1cc61 100644 --- a/src/lib/sync/scheduler.ts +++ b/src/lib/sync/scheduler.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { getAppSettings } from "@/lib/auth/settings"; import { prisma } from "@/lib/db"; +import { runStaleCheck } from "@/lib/notify/stale"; import { syncProject } from "@/lib/sync"; /** @@ -20,6 +21,8 @@ import { syncProject } from "@/lib/sync"; */ const TICK_MS = 60_000; +/** Stillstand aendert sich langsam – einmal am Tag genuegt. */ +const STALE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; /** Kleine Verschnaufpause zwischen zwei Projekten. */ const PAUSE_MS = 400; /** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */ @@ -64,18 +67,54 @@ export function startAutoSyncScheduler() { async function tick() { try { const settings = await getAppSettings(); - if (!settings.autoSyncEnabled) return; - const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; - const last = settings.lastAutoSyncAt?.getTime() ?? 0; - if (Date.now() - last < intervalMs) return; + if (settings.autoSyncEnabled) { + const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; + const last = settings.lastAutoSyncAt?.getTime() ?? 0; + if (Date.now() - last >= intervalMs) { + await runAutoSyncPass("zeitplan"); + } + } - await runAutoSyncPass("zeitplan"); + // Unabhaengig vom Abgleich: der Stillstand ergibt sich aus dem + // gespeicherten Stand, dafuer muss nichts geholt werden. + await maybeCheckStale(); } 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. + */ +async function maybeCheckStale() { + const settings = await getAppSettings(); + const last = settings.lastStaleCheckAt?.getTime() ?? 0; + if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return; + + const zuhoerer = await prisma.notificationTarget.count({ + where: { isActive: true, events: { has: "PROJECT_STALE" } }, + }); + + // Zeitstempel auch ohne Zuhoerer setzen, damit nicht jede Minute gezaehlt + // wird. + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { lastStaleCheckAt: new Date() }, + }); + + if (zuhoerer === 0) return; + + const report = await runStaleCheck(Math.max(1, settings.staleAfterDays)); + if (report.stale > 0) { + console.log( + `[nebula] Stillstand: ${report.stale} von ${report.checked} Projekten ` + + `ueber ${report.thresholdDays} Tage ohne Commit, ${report.notified} Meldungen.`, + ); + } +} + /** * Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf * waehrend eines laufenden Durchlaufs bekommt null zurueck. diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts new file mode 100644 index 0000000..3e91951 --- /dev/null +++ b/src/lib/validation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { NOTIFICATION_EVENTS } from "@/lib/notify/types"; +import { notificationTargetCreateSchema } from "./validation"; + +/** + * Dieser Test entstand aus einem Fehler: beim Ergaenzen eines neuen + * Anlasses war das Zod-Schema nicht mitgezogen worden, die API lehnte ihn + * mit 422 ab. Die Liste steht an zwei Stellen – hier wird geprueft, dass + * sie zusammenpassen. + */ + +const basis = { + name: "Handy", + channel: "NTFY" as const, + target: "https://ntfy.sh/mein-thema", +}; + +describe("notificationTargetCreateSchema", () => { + it("kennt jeden Anlass, den die Oberflaeche anbietet", () => { + for (const event of NOTIFICATION_EVENTS) { + const ergebnis = notificationTargetCreateSchema.safeParse({ + ...basis, + events: [event], + }); + expect(ergebnis.success, `Anlass ${event} wird abgelehnt`).toBe(true); + } + }); + + it("verlangt mindestens einen Anlass", () => { + expect(notificationTargetCreateSchema.safeParse({ ...basis, events: [] }).success).toBe( + false, + ); + }); + + it("weist unbekannte Anlaesse ab", () => { + expect( + notificationTargetCreateSchema.safeParse({ ...basis, events: ["WAS_AUCH_IMMER"] }) + .success, + ).toBe(false); + }); + + it("verlangt bei E-Mail eine Adresse, keine URL", () => { + const alsUrl = notificationTargetCreateSchema.safeParse({ + name: "Post", + channel: "EMAIL", + target: "https://example.com", + events: ["SYNC_FAILED"], + }); + expect(alsUrl.success).toBe(false); + + const alsAdresse = notificationTargetCreateSchema.safeParse({ + name: "Post", + channel: "EMAIL", + target: "ich@example.com", + events: ["SYNC_FAILED"], + }); + expect(alsAdresse.success).toBe(true); + }); + + it("verlangt bei Webhook und ntfy eine http-URL", () => { + for (const channel of ["WEBHOOK", "NTFY"] as const) { + expect( + notificationTargetCreateSchema.safeParse({ + name: "X", + channel, + target: "ich@example.com", + events: ["SYNC_FAILED"], + }).success, + ).toBe(false); + } + }); +}); diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 4c2eb95..0f230e0 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -146,6 +146,8 @@ export const profileUpdateSchema = z.object({ export const adminSettingsSchema = z.object({ mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(), allowRegistration: z.boolean().optional(), + /** Ab wie vielen Tagen ohne Commit ein Projekt als stillstehend gilt. */ + staleAfterDays: z.coerce.number().int().min(7).max(365).optional(), autoSyncEnabled: z.boolean().optional(), /** Untergrenze 5 Minuten – haeufiger waere den Git-APIs gegenueber unfair. */ autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(), @@ -173,7 +175,7 @@ export const adminUserUpdateSchema = z.object({ // ── Benachrichtigungen ─────────────────────────────────────── const CHANNELS = ["WEBHOOK", "NTFY", "EMAIL"] as const; -const EVENTS = ["SYNC_FAILED", "CI_FAILED", "NEW_RELEASE"] as const; +const EVENTS = ["SYNC_FAILED", "CI_FAILED", "NEW_RELEASE", "PROJECT_STALE"] as const; export const notificationTargetCreateSchema = z .object({