fix(api): treat empty strings as null in optional fields; show issues on web

- zod-utils.ts: optionalRegex/optionalEmail/optionalText with preprocess('' -> null)
- clients/organizations/services schemas now accept empty strings for optional fields
  (controlled inputs naturally produce '' instead of null)
- ApiError.prettyMessage() unfolds zod fieldErrors so users see which field failed

Reproduce: POST /api/clients with email="" or empty inn returned 400 validation_error.
Now it succeeds (empty stays null), and any real validation error names the field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 08:42:03 +03:00
parent 9807d47c8d
commit 747246197a
10 changed files with 71 additions and 24 deletions
+15
View File
@@ -4,6 +4,21 @@ export class ApiError extends Error {
constructor(public status: number, public code: string, public details?: unknown) {
super(`${status} ${code}`);
}
/** Удобочитаемое сообщение для UI: разворачивает zod issues если есть. */
prettyMessage(): string {
if (this.code === 'validation_error' && this.details && typeof this.details === 'object') {
const d = this.details as { issues?: { fieldErrors?: Record<string, string[]> } };
const fields = d.issues?.fieldErrors;
if (fields) {
const parts = Object.entries(fields)
.filter(([, msgs]) => msgs && msgs.length > 0)
.map(([k, msgs]) => `${k}: ${msgs!.join(', ')}`);
if (parts.length) return `Ошибка в полях: ${parts.join('; ')}`;
}
}
return `${this.code} (HTTP ${this.status})`;
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
+1 -1
View File
@@ -64,7 +64,7 @@ export function ClientsPage() {
setEditing(null);
await load();
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
}
}
+2 -2
View File
@@ -162,7 +162,7 @@ export function DocumentEditPage() {
setSavedAt(new Date());
}
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
} finally {
setSaving(false);
}
@@ -185,7 +185,7 @@ export function DocumentEditPage() {
await api.del(`/api/documents/${savedId}`);
navigate('/documents');
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ export function OrganizationPage() {
setDraft(saved);
setSavedAt(new Date());
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
} finally {
setSaving(false);
}
+1 -1
View File
@@ -95,7 +95,7 @@ export function ServicesPage() {
setEditing(null);
await load();
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
}
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function TemplateEditPage() {
await api.put(`/api/templates/${id}`, { name, docType, body });
setSavedAt(new Date());
} catch (e) {
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
} finally {
setSaving(false);
}