feat(core): Verschluesselung, Validierung und gemeinsame Helfer
- crypto.ts: AES-256-GCM fuer die Git-Access-Tokens, Schluessel per scrypt aus APP_SECRET abgeleitet. Chiffrat ist versioniert (v1.<iv>.<tag>.<data>), damit ein spaeterer Formatwechsel moeglich bleibt. Fehlt APP_SECRET, warnt die App und faellt auf einen Entwicklungsschluessel zurueck. - validation.ts: Zod-Schemas fuer alle Eingaben an der API-Grenze, inklusive Umwandlung leerer Strings zu null und Feldfehlern fuer die Oberflaeche. - status.ts: Metadaten zu Status, Prioritaeten und Akzentfarben an einer Stelle, damit Board, Karte und Detailseite nicht auseinanderlaufen. - utils.ts: Slug-Erzeugung, deutsche Datumsformate mit fester Zeitzone (verhindert Abweichungen zwischen Server-Render und Hydration) und relative Zeitangaben. - projects.ts: wiederverwendete Prisma-Selects. Die Verbindungsauswahl laesst tokenCipher bewusst weg, damit kein Token in eine Client-Komponente gelangt. - db.ts: Prisma-Client mit Cache ueber globalThis gegen Verbindungslecks beim Hot Reload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschluesselung der Git-Access-Tokens.
|
||||||
|
*
|
||||||
|
* Format des Chiffrats: v1.<iv>.<authTag>.<ciphertext> (jeweils base64url)
|
||||||
|
*
|
||||||
|
* Der Schluessel wird per scrypt aus APP_SECRET abgeleitet. Ohne gesetztes
|
||||||
|
* APP_SECRET wird ein Entwicklungs-Fallback benutzt – dann warnt die App
|
||||||
|
* beim Start, weil Tokens sonst faktisch im Klartext liegen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEV_FALLBACK_SECRET = "nebula-dev-secret-please-set-APP_SECRET";
|
||||||
|
const SALT = "nebula.token.v1";
|
||||||
|
const VERSION = "v1";
|
||||||
|
|
||||||
|
let warned = false;
|
||||||
|
|
||||||
|
function secret(): string {
|
||||||
|
const value = process.env.APP_SECRET;
|
||||||
|
if (value && value.length >= 16) return value;
|
||||||
|
|
||||||
|
if (!warned) {
|
||||||
|
warned = true;
|
||||||
|
console.warn(
|
||||||
|
"[nebula] APP_SECRET ist nicht (ausreichend) gesetzt – es wird ein unsicherer " +
|
||||||
|
"Entwicklungsschluessel verwendet. Bitte APP_SECRET in der .env setzen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return DEV_FALLBACK_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
function key(): Buffer {
|
||||||
|
return scryptSync(secret(), SALT, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptSecret(plaintext: string): string {
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key(), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
return [
|
||||||
|
VERSION,
|
||||||
|
iv.toString("base64url"),
|
||||||
|
cipher.getAuthTag().toString("base64url"),
|
||||||
|
encrypted.toString("base64url"),
|
||||||
|
].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptSecret(payload: string): string {
|
||||||
|
const parts = payload.split(".");
|
||||||
|
if (parts.length !== 4 || parts[0] !== VERSION) {
|
||||||
|
throw new Error("Ungueltiges Token-Format");
|
||||||
|
}
|
||||||
|
const [, ivPart, tagPart, dataPart] = parts;
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key(), Buffer.from(ivPart, "base64url"));
|
||||||
|
decipher.setAuthTag(Buffer.from(tagPart, "base64url"));
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(dataPart, "base64url")),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
return decrypted.toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entschluesselt und schluckt Fehler – z. B. wenn APP_SECRET gewechselt wurde. */
|
||||||
|
export function tryDecryptSecret(payload: string | null | undefined): string | null {
|
||||||
|
if (!payload) return null;
|
||||||
|
try {
|
||||||
|
return decryptSecret(payload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verkuerzte Darstellung fuer die UI: "ghp_…9f3a". */
|
||||||
|
export function tokenHint(token: string): string {
|
||||||
|
const trimmed = token.trim();
|
||||||
|
if (trimmed.length <= 8) return "••••";
|
||||||
|
return `${trimmed.slice(0, 4)}…${trimmed.slice(-4)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Im Dev-Modus laedt Next die Module bei jedem Hot-Reload neu. Ohne diesen
|
||||||
|
* Cache wuerde pro Reload eine neue Verbindung zur Datenbank aufgebaut.
|
||||||
|
*/
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log:
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? ["warn", "error"]
|
||||||
|
: ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { slugify } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Erzeugt einen eindeutigen Slug, notfalls mit angehaengtem Zaehler. */
|
||||||
|
export async function uniqueSlug(name: string, ignoreId?: string): Promise<string> {
|
||||||
|
const base = slugify(name) || "projekt";
|
||||||
|
let candidate = base;
|
||||||
|
let counter = 2;
|
||||||
|
|
||||||
|
// In der Praxis endet die Schleife nach wenigen Runden.
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
const existing = await prisma.project.findUnique({
|
||||||
|
where: { slug: candidate },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing || existing.id === ignoreId) return candidate;
|
||||||
|
candidate = `${base}-${counter}`;
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
return `${base}-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verbindungsfelder, die an den Browser gehen duerfen.
|
||||||
|
* tokenCipher ist bewusst nicht dabei.
|
||||||
|
*/
|
||||||
|
export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>()({
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
provider: true,
|
||||||
|
baseUrl: true,
|
||||||
|
username: true,
|
||||||
|
tokenHint: true,
|
||||||
|
lastCheckedAt: true,
|
||||||
|
lastCheckOk: true,
|
||||||
|
lastCheckMessage: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||||
|
connection: { select: connectionSafeSelect },
|
||||||
|
snapshot: true,
|
||||||
|
_count: { select: { notes: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectDetailInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||||
|
connection: { select: connectionSafeSelect },
|
||||||
|
snapshot: true,
|
||||||
|
notes: {
|
||||||
|
orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
|
||||||
|
},
|
||||||
|
_count: { select: { notes: true } },
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { ProjectStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export type StatusMeta = {
|
||||||
|
value: ProjectStatus;
|
||||||
|
label: string;
|
||||||
|
short: string;
|
||||||
|
/** Tailwind-Klassen fuer Pills/Badges */
|
||||||
|
className: string;
|
||||||
|
/** Reine Farbe fuer Punkte, Balken und Glows */
|
||||||
|
color: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_ORDER: ProjectStatus[] = [
|
||||||
|
"IDEA",
|
||||||
|
"PLANNING",
|
||||||
|
"OPEN",
|
||||||
|
"IN_DEVELOPMENT",
|
||||||
|
"DONE",
|
||||||
|
"ARCHIVED",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const STATUS_META: Record<ProjectStatus, StatusMeta> = {
|
||||||
|
IDEA: {
|
||||||
|
value: "IDEA",
|
||||||
|
label: "Idee",
|
||||||
|
short: "Idee",
|
||||||
|
className: "bg-cyan-500/12 text-cyan-200 ring-cyan-400/30",
|
||||||
|
color: "#22d3ee",
|
||||||
|
description: "Noch ein Gedanke – nichts gebaut, nichts entschieden.",
|
||||||
|
},
|
||||||
|
PLANNING: {
|
||||||
|
value: "PLANNING",
|
||||||
|
label: "In Planung",
|
||||||
|
short: "Planung",
|
||||||
|
className: "bg-blue-500/12 text-blue-200 ring-blue-400/30",
|
||||||
|
color: "#60a5fa",
|
||||||
|
description: "Scope, Stack und Schnitt werden gerade festgelegt.",
|
||||||
|
},
|
||||||
|
OPEN: {
|
||||||
|
value: "OPEN",
|
||||||
|
label: "Offen",
|
||||||
|
short: "Offen",
|
||||||
|
className: "bg-amber-500/12 text-amber-200 ring-amber-400/30",
|
||||||
|
color: "#fbbf24",
|
||||||
|
description: "Startklar, aber aktuell liegt es still.",
|
||||||
|
},
|
||||||
|
IN_DEVELOPMENT: {
|
||||||
|
value: "IN_DEVELOPMENT",
|
||||||
|
label: "In Entwicklung",
|
||||||
|
short: "Aktiv",
|
||||||
|
className: "bg-violet-500/12 text-violet-200 ring-violet-400/30",
|
||||||
|
color: "#a78bfa",
|
||||||
|
description: "Wird gerade aktiv gebaut.",
|
||||||
|
},
|
||||||
|
DONE: {
|
||||||
|
value: "DONE",
|
||||||
|
label: "Fertig",
|
||||||
|
short: "Fertig",
|
||||||
|
className: "bg-emerald-500/12 text-emerald-200 ring-emerald-400/30",
|
||||||
|
color: "#34d399",
|
||||||
|
description: "Ausgeliefert und abgeschlossen.",
|
||||||
|
},
|
||||||
|
ARCHIVED: {
|
||||||
|
value: "ARCHIVED",
|
||||||
|
label: "Archiviert",
|
||||||
|
short: "Archiv",
|
||||||
|
className: "bg-slate-500/12 text-slate-300 ring-slate-400/25",
|
||||||
|
color: "#94a3b8",
|
||||||
|
description: "Ruht im Archiv – bleibt erhalten, stoert aber nicht mehr.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRIORITY_META: Record<number, { label: string; color: string }> = {
|
||||||
|
1: { label: "Niedrig", color: "#64748b" },
|
||||||
|
2: { label: "Normal", color: "#38bdf8" },
|
||||||
|
3: { label: "Hoch", color: "#fb923c" },
|
||||||
|
4: { label: "Kritisch", color: "#f43f5e" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccentKey = "violet" | "cyan" | "emerald" | "amber" | "rose" | "blue";
|
||||||
|
|
||||||
|
export const ACCENTS: Record<AccentKey, { label: string; from: string; to: string }> = {
|
||||||
|
violet: { label: "Violett", from: "#8b5cf6", to: "#d946ef" },
|
||||||
|
cyan: { label: "Cyan", from: "#06b6d4", to: "#3b82f6" },
|
||||||
|
emerald: { label: "Smaragd", from: "#10b981", to: "#84cc16" },
|
||||||
|
amber: { label: "Bernstein", from: "#f59e0b", to: "#f43f5e" },
|
||||||
|
rose: { label: "Rose", from: "#f43f5e", to: "#8b5cf6" },
|
||||||
|
blue: { label: "Blau", from: "#3b82f6", to: "#6366f1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function accentOf(key: string | null | undefined) {
|
||||||
|
return ACCENTS[(key ?? "violet") as AccentKey] ?? ACCENTS.violet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusOf(status: ProjectStatus | string): StatusMeta {
|
||||||
|
return STATUS_META[status as ProjectStatus] ?? STATUS_META.IDEA;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/** Kleine Helfer ohne Abhaengigkeiten. */
|
||||||
|
|
||||||
|
export function cn(...classes: Array<string | false | null | undefined>): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae")
|
||||||
|
.replace(/ö/g, "oe")
|
||||||
|
.replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss")
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
});
|
||||||
|
|
||||||
|
const DATETIME_FORMAT = new Intl.DateTimeFormat("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: "Europe/Berlin",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function formatDate(value: Date | string | null | undefined): string {
|
||||||
|
if (!value) return "–";
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return "–";
|
||||||
|
return DATE_FORMAT.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(value: Date | string | null | undefined): string {
|
||||||
|
if (!value) return "–";
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return "–";
|
||||||
|
return DATETIME_FORMAT.format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "vor 3 Tagen". Bewusst grob gerundet – kleine Abweichungen zwischen
|
||||||
|
* Server-Render und Hydration fallen damit nicht auf.
|
||||||
|
*/
|
||||||
|
export function formatRelative(value: Date | string | null | undefined): string {
|
||||||
|
if (!value) return "nie";
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return "nie";
|
||||||
|
|
||||||
|
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
|
||||||
|
if (seconds < 60) return "gerade eben";
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `vor ${minutes} Min.`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
if (hours < 24) return `vor ${hours} Std.`;
|
||||||
|
const days = Math.round(hours / 24);
|
||||||
|
if (days < 31) return `vor ${days} ${days === 1 ? "Tag" : "Tagen"}`;
|
||||||
|
const months = Math.round(days / 30);
|
||||||
|
if (months < 12) return `vor ${months} Mon.`;
|
||||||
|
return `vor ${Math.round(months / 12)} J.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(value: string, max = 120): string {
|
||||||
|
if (value.length <= max) return value;
|
||||||
|
return `${value.slice(0, max - 1).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstLine(value: string | null | undefined): string {
|
||||||
|
if (!value) return "";
|
||||||
|
return value.split("\n")[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTags(input: string): string[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
input
|
||||||
|
.split(",")
|
||||||
|
.map((tag) => tag.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
).slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(value: number): string {
|
||||||
|
return new Intl.NumberFormat("de-DE").format(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const STATUS = ["IDEA", "OPEN", "PLANNING", "IN_DEVELOPMENT", "DONE", "ARCHIVED"] as const;
|
||||||
|
const PROVIDERS = ["GITHUB", "GITLAB", "GITEA"] as const;
|
||||||
|
const ACCENTS = ["violet", "cyan", "emerald", "amber", "rose", "blue"] as const;
|
||||||
|
|
||||||
|
const emptyToNull = (value: unknown) =>
|
||||||
|
typeof value === "string" && value.trim() === "" ? null : value;
|
||||||
|
|
||||||
|
export const projectCreateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Name darf nicht leer sein").max(120),
|
||||||
|
summary: z.preprocess(emptyToNull, z.string().trim().max(280).nullable().optional()),
|
||||||
|
description: z.preprocess(emptyToNull, z.string().trim().max(20000).nullable().optional()),
|
||||||
|
status: z.enum(STATUS).default("IDEA"),
|
||||||
|
priority: z.coerce.number().int().min(1).max(4).default(2),
|
||||||
|
progress: z.coerce.number().int().min(0).max(100).default(0),
|
||||||
|
accent: z.enum(ACCENTS).default("violet"),
|
||||||
|
tags: z.array(z.string().trim().min(1).max(30)).max(12).default([]),
|
||||||
|
favorite: z.boolean().default(false),
|
||||||
|
repoUrl: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
|
||||||
|
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||||
|
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
|
||||||
|
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectUpdateSchema = projectCreateSchema.partial();
|
||||||
|
|
||||||
|
export const noteCreateSchema = z.object({
|
||||||
|
title: z.preprocess(emptyToNull, z.string().trim().max(160).nullable().optional()),
|
||||||
|
content: z.string().trim().min(1, "Notiz darf nicht leer sein").max(20000),
|
||||||
|
pinned: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const noteUpdateSchema = noteCreateSchema.partial();
|
||||||
|
|
||||||
|
export const connectionCreateSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "Name darf nicht leer sein").max(80),
|
||||||
|
provider: z.enum(PROVIDERS),
|
||||||
|
baseUrl: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "Basis-URL fehlt")
|
||||||
|
.max(300)
|
||||||
|
.refine((value) => /^https?:\/\//i.test(value), "URL muss mit http:// oder https:// beginnen"),
|
||||||
|
username: z.preprocess(emptyToNull, z.string().trim().max(120).nullable().optional()),
|
||||||
|
token: z.preprocess(emptyToNull, z.string().trim().max(500).nullable().optional()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const connectionUpdateSchema = connectionCreateSchema.partial();
|
||||||
|
|
||||||
|
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>;
|
||||||
|
export type ProjectUpdateInput = z.infer<typeof projectUpdateSchema>;
|
||||||
|
export type NoteCreateInput = z.infer<typeof noteCreateSchema>;
|
||||||
|
export type ConnectionCreateInput = z.infer<typeof connectionCreateSchema>;
|
||||||
|
|
||||||
|
/** Formt Zod-Fehler in ein flaches Objekt fuer die API-Antwort. */
|
||||||
|
export function formatZodError(error: z.ZodError): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const key = issue.path.join(".") || "_";
|
||||||
|
if (!result[key]) result[key] = issue.message;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { GitProvider, Note, Project, ProjectStatus, RepoSnapshot } from "@prisma/client";
|
||||||
|
import type { BranchInfo, CommitInfo, ReleaseInfo, TagInfo } from "@/lib/git/types";
|
||||||
|
|
||||||
|
/** Verbindung ohne Geheimnisse – so wird sie an den Browser gegeben. */
|
||||||
|
export interface ConnectionSafe {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: GitProvider;
|
||||||
|
baseUrl: string;
|
||||||
|
username: string | null;
|
||||||
|
tokenHint: string | null;
|
||||||
|
lastCheckedAt: Date | string | null;
|
||||||
|
lastCheckOk: boolean | null;
|
||||||
|
lastCheckMessage: string | null;
|
||||||
|
createdAt: Date | string;
|
||||||
|
updatedAt: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionWithCount extends ConnectionSafe {
|
||||||
|
_count?: { projects: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectListItem extends Project {
|
||||||
|
connection: ConnectionSafe | null;
|
||||||
|
snapshot: RepoSnapshot | null;
|
||||||
|
_count: { notes: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectDetail extends Project {
|
||||||
|
connection: ConnectionSafe | null;
|
||||||
|
snapshot: RepoSnapshot | null;
|
||||||
|
notes: Note[];
|
||||||
|
_count: { notes: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepoListEntry {
|
||||||
|
fullPath: string;
|
||||||
|
owner: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
htmlUrl: string;
|
||||||
|
defaultBranch: string | null;
|
||||||
|
visibility: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { BranchInfo, CommitInfo, Note, ProjectStatus, ReleaseInfo, RepoSnapshot, TagInfo };
|
||||||
|
|
||||||
|
/** Prisma liefert Json als unknown – hier bequem in Arrays zurueckholen. */
|
||||||
|
export function asArray<T>(value: unknown): T[] {
|
||||||
|
return Array.isArray(value) ? (value as T[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asRecord(value: unknown): Record<string, number> {
|
||||||
|
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, number>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user