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:
2026-07-25 19:33:35 +02:00
co-authored by Claude Opus 5
parent 58c493ddbd
commit c9e09a2762
7 changed files with 473 additions and 0 deletions
+96
View File
@@ -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);
}