feat: add client-side validation utilities and debugging tools
Build and Push Docker Image / build (push) Successful in 1m23s

- Implemented client-side validation functions for tax ID, VAT ID, IBAN, BIC, and website URL.
- Added debug logging functionality to assist in development.
- Created a comprehensive validation function for company form data.

feat: initialize database with Prisma migrations

- Added a server-side script to run Prisma migrations and check database health.
- Ensured safe initialization of the database to prevent concurrent migrations.

feat: comprehensive server-side error logging

- Developed an error logging system that captures detailed error context, including request details and stack traces.
- Implemented logging functions for different error types (route, action, database, API, startup).

fix: validate user ID existence in audit logs

- Updated the logging function to validate that the user ID exists in the database before logging actions.

fix: update schemas for optional fields and validation

- Modified schemas to allow for nullable fields and refined validation logic for tax ID, VAT ID, IBAN, and BIC.

feat: enhance error boundary for better debugging

- Improved error boundary to log detailed error information in development mode.
- Added a debug panel to the main application layout for real-time error tracking.

feat: implement company deletion functionality in admin routes

- Added a new API route for deleting companies with appropriate logging.
- Integrated delete confirmation in the admin interface for better user experience.

fix: handle API errors gracefully

- Wrapped API actions in try-catch blocks to log errors and return appropriate responses.

feat: generate and save invoice PDFs

- Implemented functionality to generate and save invoice PDFs upon status updates.
- Added a new column in the database for storing the URL of the generated PDF.

chore: update Docker image reference

- Changed the Docker image reference to point to the new Git repository.

chore: update package dependencies

- Added @radix-ui/react-tooltip for enhanced UI components.
- Updated package-lock.json to reflect new dependencies.
This commit is contained in:
hwinkel
2026-05-03 08:46:58 +02:00
parent c3e7a97c8a
commit b22e5baa5c
26 changed files with 1573 additions and 134 deletions
+136
View File
@@ -0,0 +1,136 @@
/**
* Error Logging Usage Examples
*
* Use these patterns in your routes to get better error debugging
*/
// ============================================================================
// PATTERN 1: In a loader (data fetching)
// ============================================================================
import { json, type LoaderFunction } from "react-router";
import { logRouteError } from "@/lib/error-logger.server";
import prisma from "@/lib/prisma.server";
export const loader: LoaderFunction = async ({ request, params }) => {
try {
const company = await prisma.company.findUnique({
where: { id: params.id },
});
if (!company) {
throw new Error(`Company not found: ${params.id}`);
}
return json({ company });
} catch (error) {
logRouteError(error, {
request,
route: request.url,
userId: params.userId, // if available
metadata: {
companyId: params.id,
},
});
throw error; // Re-throw to let React Router handle it
}
};
// ============================================================================
// PATTERN 2: In an action (mutation/form submission)
// ============================================================================
import { logActionError } from "@/lib/error-logger.server";
export const action: LoaderFunction = async ({ request, params }) => {
try {
const formData = await request.formData();
const result = await prisma.company.update({
where: { id: params.id },
data: { name: formData.get("name") },
});
return json({ success: true, result });
} catch (error) {
logActionError(error, {
request,
action: "UPDATE_COMPANY",
metadata: {
companyId: params.id,
method: request.method,
},
});
throw error;
}
};
// ============================================================================
// PATTERN 3: In an API route (POST/PUT/DELETE)
// ============================================================================
import { logApiError } from "@/lib/error-logger.server";
export const action: LoaderFunction = async ({ request }) => {
try {
const data = await request.json();
// Validate
if (!data.name) {
return json(
{ error: "Name is required" },
{ status: 400 }
);
}
// Process
const result = await prisma.company.create({ data });
return json({ success: true, result }, { status: 201 });
} catch (error) {
logApiError(error, {
request,
endpoint: "/api/companies",
statusCode: 500,
metadata: {
method: request.method,
},
});
return json(
{ error: "Internal server error" },
{ status: 500 }
);
}
};
// ============================================================================
// PATTERN 4: Database operations with error context
// ============================================================================
import { logDatabaseError } from "@/lib/error-logger.server";
async function fetchCompanyWithUsers(companyId: string) {
try {
return await prisma.company.findUnique({
where: { id: companyId },
include: { users: true },
});
} catch (error) {
logDatabaseError(error, "company.findUnique", {
metadata: { companyId },
});
throw error;
}
}
// ============================================================================
// OUTPUT EXAMPLE (Server Console)
// ============================================================================
/*
================================================================================
[ROUTE_ERROR] | 2026-05-02T22:10:30.123Z | Company not found | route: /companies/123 | GET /companies/123 | user: user-id-456
Stack at Object.loader (file:///app/routes/companies.$id.tsx:12:13)
at process._tickCallback (internal/timers.ts:203:26)
Metadata: {
"companyId": "123"
}
================================================================================
*/
+244
View File
@@ -0,0 +1,244 @@
/**
* Client-side validation and debugging utilities
* Mirrors server-side validation for real-time user feedback
*/
// Debug mode - enable via localStorage: localStorage.setItem('DEBUG_MODE', 'true')
export function isDebugMode(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem("DEBUG_MODE") === "true";
}
export function setDebugMode(enabled: boolean): void {
if (typeof window === "undefined") return;
if (enabled) {
localStorage.setItem("DEBUG_MODE", "true");
console.log("✅ DEBUG MODE ENABLED");
} else {
localStorage.removeItem("DEBUG_MODE");
console.log("❌ DEBUG MODE DISABLED");
}
}
/**
* Log error to browser console (only in debug mode)
*/
export function debugLog(type: string, message: string, data?: unknown): void {
if (!isDebugMode()) return;
const style = {
error: "color: #ef4444; font-weight: bold;",
warning: "color: #f97316; font-weight: bold;",
info: "color: #3b82f6; font-weight: bold;",
success: "color: #22c55e; font-weight: bold;",
};
const typeStyle = style[type as keyof typeof style] || style.info;
console.log(`%c[${type.toUpperCase()}] ${message}`, typeStyle, data);
}
/**
* Validation error type
*/
export interface ValidationError {
field: string;
message: string;
}
/**
* Validate tax ID (Steuernummer): 10 digits
*/
export function validateTaxId(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^\d{10}$/.test(val)) {
return { field: "taxId", message: "Steuernummer muss 10 Ziffern haben" };
}
return null;
}
/**
* Validate VAT ID (USt-IdNr): DE + 9 digits
*/
export function validateVatId(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^DE\d{9}$/.test(val)) {
return { field: "vatId", message: "USt-IdNr. muss im Format DE + 9 Ziffern sein" };
}
return null;
}
/**
* Validate IBAN
*/
export function validateIban(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(val)) {
return { field: "bankIban", message: "Ungültige IBAN" };
}
return null;
}
/**
* Validate BIC
*/
export function validateBic(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^[A-Z0-9]{8,11}$/.test(val)) {
return { field: "bankBic", message: "Ungültiger BIC" };
}
return null;
}
/**
* Validate website URL
*/
export function validateWebsite(val: string | null | undefined): ValidationError | null {
if (!val || val === "") return null;
if (!/^https?:\/\//.test(val)) {
return { field: "website", message: "Website muss mit http:// oder https:// beginnen" };
}
if (val.length > 255) {
return { field: "website", message: "Website darf maximal 255 Zeichen sein" };
}
return null;
}
/**
* Validate company form data
*/
export interface CompanyFormData {
name: string;
address: string;
zip: string;
city: string;
taxId?: string;
vatId?: string;
website?: string;
bankIban?: string;
bankBic?: string;
email?: string;
phone?: string;
legalForm?: string;
country?: string;
bankName?: string;
}
export function validateCompanyForm(
data: CompanyFormData
): ValidationError[] {
const errors: ValidationError[] = [];
// Required fields
if (!data.name || data.name.trim() === "") {
errors.push({ field: "name", message: "Firmenname erforderlich" });
} else if (data.name.length > 255) {
errors.push({ field: "name", message: "Firmenname darf maximal 255 Zeichen sein" });
}
if (!data.address || data.address.trim() === "") {
errors.push({ field: "address", message: "Adresse erforderlich" });
} else if (data.address.length > 500) {
errors.push({ field: "address", message: "Adresse darf maximal 500 Zeichen sein" });
}
if (!data.zip || data.zip.trim() === "") {
errors.push({ field: "zip", message: "PLZ erforderlich" });
} else if (!/^[\d\s-]*$/.test(data.zip)) {
errors.push({ field: "zip", message: "PLZ darf nur Zahlen, Bindestriche und Leerzeichen enthalten" });
} else if (data.zip.length > 20) {
errors.push({ field: "zip", message: "PLZ darf maximal 20 Zeichen sein" });
}
if (!data.city || data.city.trim() === "") {
errors.push({ field: "city", message: "Stadt erforderlich" });
} else if (data.city.length > 100) {
errors.push({ field: "city", message: "Stadt darf maximal 100 Zeichen sein" });
}
// Optional fields with validation
if (data.taxId) {
const taxError = validateTaxId(data.taxId);
if (taxError) errors.push(taxError);
}
if (data.vatId) {
const vatError = validateVatId(data.vatId);
if (vatError) errors.push(vatError);
}
if (data.website) {
const webError = validateWebsite(data.website);
if (webError) errors.push(webError);
}
if (data.bankIban) {
const ibanError = validateIban(data.bankIban);
if (ibanError) errors.push(ibanError);
}
if (data.bankBic) {
const bicError = validateBic(data.bankBic);
if (bicError) errors.push(bicError);
}
if (data.email && data.email.trim()) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.push({ field: "email", message: "Ungültige E-Mail-Adresse" });
}
}
if (data.phone && data.phone.length > 20) {
errors.push({ field: "phone", message: "Telefonnummer darf maximal 20 Zeichen sein" });
}
if (data.legalForm && data.legalForm.length > 100) {
errors.push({ field: "legalForm", message: "Rechtsform darf maximal 100 Zeichen sein" });
}
if (data.country && data.country.length > 2) {
errors.push({ field: "country", message: "Ländercode darf maximal 2 Zeichen sein" });
}
if (data.bankName && data.bankName.length > 255) {
errors.push({ field: "bankName", message: "Bankname darf maximal 255 Zeichen sein" });
}
// Debug logging
if (errors.length > 0) {
debugLog("warning", `Validation failed: ${errors.length} error(s)`, errors);
} else {
debugLog("success", "Validation passed", data);
}
return errors;
}
/**
* Validate API response errors
*/
export function handleApiError(error: unknown, endpoint: string): void {
debugLog("error", `API Error from ${endpoint}`, error);
if (error instanceof Response) {
debugLog("error", `HTTP ${error.status}: ${error.statusText}`, error);
} else if (error instanceof Error) {
debugLog("error", error.message, error.stack);
} else {
debugLog("error", "Unknown error", error);
}
}
/**
* Get error message for a specific field
*/
export function getFieldError(field: string, errors: ValidationError[]): string | null {
const error = errors.find((e) => e.field === field);
return error?.message || null;
}
/**
* Check if field has error
*/
export function hasFieldError(field: string, errors: ValidationError[]): boolean {
return errors.some((e) => e.field === field);
}
+73
View File
@@ -0,0 +1,73 @@
import { execSync } from "node:child_process";
import prisma from "./prisma.server";
let initStarted = false;
let initCompleted = false;
/**
* Run Prisma migrations to bring database schema up to date
*/
async function runMigrations(): Promise<void> {
try {
console.log("[DB Init] Running Prisma migrations...");
execSync("npx prisma migrate deploy", {
stdio: "inherit",
env: { ...process.env },
});
console.log("[DB Init] ✓ Migrations completed successfully");
} catch (error) {
console.error("[DB Init] ✗ Migration failed:", error);
throw new Error("Database migration failed. See logs above.");
}
}
/**
* Initialize database: run all pending migrations
* Safe to call multiple times (idempotent)
*/
export async function initializeDatabase(): Promise<void> {
// Prevent concurrent initialization attempts
if (initCompleted) return;
if (initStarted) {
// Wait for initialization to complete
while (!initCompleted) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return;
}
initStarted = true;
try {
await runMigrations();
console.log("[DB Init] ✓ Database initialization complete");
} catch (error) {
console.error("[DB Init] Fatal error during database initialization:", error);
throw error;
} finally {
initCompleted = true;
}
}
/**
* Check database health (non-blocking)
*/
export async function checkDatabaseHealth(): Promise<{
connected: boolean;
isEmpty: boolean;
error?: string;
}> {
try {
const userCount = await prisma.user.count();
return {
connected: true,
isEmpty: userCount === 0,
};
} catch (error) {
return {
connected: false,
isEmpty: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Comprehensive server-side error logging for debugging
* Captures errors with full context: stack traces, request details, environment
*/
export interface ErrorContext {
request?: Request;
userId?: string | null;
route?: string;
metadata?: Record<string, unknown>;
}
export interface ErrorLogEntry {
timestamp: string;
type: string;
message: string;
stack?: string;
context?: {
method?: string;
url?: string;
route?: string;
userId?: string | null;
ipAddress?: string | null;
userAgent?: string;
metadata?: Record<string, unknown>;
};
environment: string;
}
/**
* Extract error message and stack from any error type
*/
function extractErrorInfo(error: unknown): { message: string; stack?: string } {
if (error instanceof Error) {
return {
message: error.message,
stack: error.stack,
};
}
if (typeof error === "string") {
return { message: error };
}
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
return {
message: (err.message as string) || String(error),
stack: (err.stack as string) || undefined,
};
}
return { message: String(error) };
}
/**
* Extract request context
*/
function extractRequestContext(
request?: Request
): ErrorLogEntry["context"] {
if (!request) return {};
const url = new URL(request.url);
const ipAddress =
request.headers.get("x-forwarded-for") ??
request.headers.get("x-real-ip") ??
null;
const userAgent = request.headers.get("user-agent");
return {
method: request.method,
url: url.pathname + url.search,
ipAddress,
userAgent: userAgent ?? undefined,
};
}
/**
* Build comprehensive error log entry
*/
function buildErrorLogEntry(
type: string,
error: unknown,
context?: ErrorContext
): ErrorLogEntry {
const { message, stack } = extractErrorInfo(error);
const requestContext = extractRequestContext(context?.request);
return {
timestamp: new Date().toISOString(),
type,
message,
stack,
context: {
...requestContext,
route: context?.route,
userId: context?.userId,
metadata: context?.metadata,
},
environment: process.env.NODE_ENV || "development",
};
}
/**
* Format error log for console output
*/
function formatErrorLog(entry: ErrorLogEntry): string {
const parts = [
`[${entry.type}]`,
`${entry.timestamp}`,
entry.message,
];
if (entry.context?.route) parts.push(`route: ${entry.context.route}`);
if (entry.context?.method && entry.context?.url) {
parts.push(`${entry.context.method} ${entry.context.url}`);
}
if (entry.context?.userId) parts.push(`user: ${entry.context.userId}`);
if (entry.context?.ipAddress) parts.push(`ip: ${entry.context.ipAddress}`);
let output = parts.join(" | ");
if (entry.stack) {
output += "\n" + entry.stack;
}
if (entry.context?.metadata) {
output += "\nMetadata: " + JSON.stringify(entry.context.metadata, null, 2);
}
return output;
}
/**
* Log a route/loader error
*/
export function logRouteError(
error: unknown,
context: ErrorContext & { route: string }
): void {
const entry = buildErrorLogEntry("ROUTE_ERROR", error, context);
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log an action/mutation error
*/
export function logActionError(
error: unknown,
context: ErrorContext & { action: string }
): void {
const entry = buildErrorLogEntry("ACTION_ERROR", error, {
...context,
metadata: {
action: context.action,
...context.metadata,
},
});
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log a database error
*/
export function logDatabaseError(
error: unknown,
operation: string,
context?: Omit<ErrorContext, "metadata">
): void {
const entry = buildErrorLogEntry("DATABASE_ERROR", error, {
...context,
metadata: { operation },
});
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log an API error
*/
export function logApiError(
error: unknown,
context: ErrorContext & { endpoint: string; statusCode?: number }
): void {
const entry = buildErrorLogEntry("API_ERROR", error, {
...context,
metadata: {
endpoint: context.endpoint,
statusCode: context.statusCode || 500,
...context.metadata,
},
});
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log a server startup error
*/
export function logStartupError(error: unknown): void {
const entry = buildErrorLogEntry("STARTUP_ERROR", error);
console.error("\n" + "=".repeat(80));
console.error("🚨 CRITICAL: Server failed to start");
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
/**
* Log a generic error with type
*/
export function logError(
type: string,
error: unknown,
context?: ErrorContext
): void {
const entry = buildErrorLogEntry(type, error, context);
console.error("\n" + "=".repeat(80));
console.error(formatErrorLog(entry));
console.error("=".repeat(80) + "\n");
}
+13 -1
View File
@@ -47,9 +47,21 @@ export async function log({
: undefined;
try {
// Validate that userId exists in the database if provided
let validatedUserId = userId;
if (userId) {
const userExists = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!userExists) {
validatedUserId = null; // User doesn't exist, log as anonymous
}
}
await prisma.auditLog.create({
data: {
userId: userId ?? null,
userId: validatedUserId ?? null,
action,
entity: entity ?? null,
entityId: entityId ?? null,
+24 -10
View File
@@ -35,7 +35,7 @@ export const taxRateSchema = z
export const ibanSchema = z
.string()
.refine(
(iban) => /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(iban),
(iban) => iban === "" || /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(iban),
"Ungültige IBAN"
);
@@ -44,14 +44,20 @@ export const ibanSchema = z
*/
export const taxIdSchema = z
.string()
.regex(/^\d{10}$/, "Steuernummer muss 10 Ziffern haben");
.refine(
(val) => val === "" || /^\d{10}$/.test(val),
"Steuernummer muss 10 Ziffern haben"
);
/**
* VAT ID (Umsatzsteuer-IdNr): DE + 9 digits
*/
export const vatIdSchema = z
.string()
.regex(/^DE\d{9}$/, "USt-IdNr. muss im Format DE + 9 Ziffern sein");
.refine(
(val) => val === "" || /^DE\d{9}$/.test(val),
"USt-IdNr. muss im Format DE + 9 Ziffern sein"
);
// ===== Invoice Schemas =====
@@ -131,8 +137,8 @@ export const companySchema = z.object({
.string()
.max(100, "Rechtsform darf maximal 100 Zeichen sein")
.optional(),
taxId: taxIdSchema.optional(),
vatId: vatIdSchema.optional(),
taxId: taxIdSchema.nullable(),
vatId: vatIdSchema.nullable(),
address: z
.string()
.min(1, "Adresse erforderlich")
@@ -162,14 +168,22 @@ export const companySchema = z.object({
.optional(),
website: z
.string()
.url("Ungültige URL")
.refine(
(val) => val === "" || /^https?:\/\//.test(val),
"Website muss mit http:// oder https:// beginnen"
)
.max(255, "Website darf maximal 255 Zeichen sein")
.optional(),
bankIban: ibanSchema.optional(),
.optional()
.or(z.literal("")),
bankIban: ibanSchema.nullable(),
bankBic: z
.string()
.regex(/^[A-Z0-9]{8,11}$/, "Ungültiger BIC")
.optional(),
.refine(
(val) => val === "" || /^[A-Z0-9]{8,11}$/.test(val),
"Ungültiger BIC"
)
.optional()
.or(z.literal("")),
bankName: z
.string()
.max(255, "Bankname darf maximal 255 Zeichen sein")