Files
nebula/src/lib/validation.ts
T
Florian WildeandClaude Opus 5 6f0d6e1ca9 feat(admin): Abgleich einstellbar und Schalter je Projekt
Adminbereich, Abschnitt "Automatischer Abgleich":
- ein- und ausschalten
- Abstand von 15 Minuten bis taeglich
- Projekte je Durchlauf
- fertige und archivierte auslassen
- Zustand auf einen Blick: verknuepfte Projekte, letzter und naechster
  Durchlauf, Kurzbericht des letzten Laufs
- Knopf "Jetzt abgleichen" fuer einen sofortigen Durchlauf

Der Endpunkt POST /api/admin/sync/run dient zugleich als Einstieg fuer einen
externen Zeitgeber, falls jemand lieber von aussen taktet als den eingebauten
Automatismus zu nutzen.

Im Projektdialog laesst sich ein einzelnes Projekt vom Abgleich ausnehmen -
nuetzlich bei einem Repo, dessen Token staendig Fehler wirft. Manuell
synchronisieren geht unabhaengig davon immer.

Die Zahl der in Frage kommenden Projekte wird ueber alle Konten gezaehlt: der
Abgleich ist ein Serverauftrag, keine Dateneinsicht. Ein Administrator sieht
dadurch weiterhin keine fremden Projekte.

Nachgewiesen: Durchlauf 1 aktualisiert das echte Repo und meldet das kaputte
als Fehlschlag, Durchlauf 2 stellt das kaputte zurueck, das ausgenommene
Projekt bleibt in beiden unberuehrt. Der Zeitplan loeste eigenstaendig aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:38:19 +02:00

154 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()),
/** Am automatischen Abgleich teilnehmen. */
autoSync: z.boolean().default(true),
});
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();
// ── Benutzer, Anmeldung, Verwaltung ──────────────────────────
/** Benutzernamen werden immer kleingeschrieben gespeichert und verglichen. */
export const usernameSchema = z
.string()
.trim()
.toLowerCase()
.min(3, "Mindestens 3 Zeichen")
.max(32, "Hoechstens 32 Zeichen")
.regex(
/^[a-z0-9][a-z0-9._-]*$/,
"Erlaubt sind Kleinbuchstaben, Ziffern, Punkt, Bindestrich und Unterstrich",
);
const passwordField = z.string().min(1, "Passwort fehlt").max(200);
const emailField = z.preprocess(
emptyToNull,
z.string().trim().toLowerCase().email("Keine gueltige E-Mail-Adresse").nullable().optional(),
);
const displayNameField = z.preprocess(
emptyToNull,
z.string().trim().max(80).nullable().optional(),
);
export const setupSchema = z.object({
username: usernameSchema,
displayName: displayNameField,
email: emailField,
password: passwordField,
/** Betriebsart, die beim Einrichten gewaehlt wurde. */
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).default("SINGLE_USER"),
});
export const loginSchema = z.object({
username: z.string().trim().toLowerCase().min(1, "Benutzername fehlt").max(64),
password: passwordField,
});
export const registerSchema = z.object({
username: usernameSchema,
displayName: displayNameField,
email: emailField,
password: passwordField,
});
export const changePasswordSchema = z.object({
/** Entfaellt bei Konten, die bisher nur einen Passkey haben. */
currentPassword: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
newPassword: passwordField,
});
export const profileUpdateSchema = z.object({
displayName: displayNameField,
email: emailField,
});
export const adminSettingsSchema = z.object({
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
allowRegistration: z.boolean().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(),
autoSyncActiveOnly: z.boolean().optional(),
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(),
});
export const adminUserCreateSchema = z.object({
username: usernameSchema,
displayName: displayNameField,
email: emailField,
password: passwordField,
role: z.enum(["ADMIN", "USER"]).default("USER"),
});
export const adminUserUpdateSchema = z.object({
displayName: displayNameField,
email: emailField,
role: z.enum(["ADMIN", "USER"]).optional(),
isActive: z.boolean().optional(),
/** Neues Passwort setzen; leer lassen heisst unveraendert. */
password: z.preprocess(emptyToNull, z.string().max(200).nullable().optional()),
});
export const passkeyNameSchema = z.object({
name: z.preprocess(emptyToNull, z.string().trim().max(60).nullable().optional()),
});
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;
}