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
+31
View File
@@ -0,0 +1,31 @@
import { z } from 'zod';
/**
* Превращает пустые строки и whitespace-only в null до валидации.
* Используется в API-схемах для optional-полей: фронт может слать "" вместо null
* (стандартный паттерн controlled inputs), API трактует это как «не задано».
*/
const blankToNull = (v: unknown): unknown => {
if (typeof v === 'string' && v.trim() === '') return null;
return v;
};
/** Опциональная строка с валидатором — пустая строка трактуется как null. */
export function optionalString<T extends z.ZodType<string>>(validator: T) {
return z.preprocess(blankToNull, validator.nullable());
}
/** Опциональная строка без дополнительных правил, ограниченная max-длиной. */
export function optionalText(max = 1000) {
return z.preprocess(blankToNull, z.string().max(max).nullable());
}
/** Опциональный email. */
export function optionalEmail() {
return z.preprocess(blankToNull, z.string().email().nullable());
}
/** Опциональная строка по regex. */
export function optionalRegex(re: RegExp) {
return z.preprocess(blankToNull, z.string().regex(re).nullable());
}
+8 -9
View File
@@ -2,19 +2,18 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { optionalEmail, optionalRegex, optionalText } from '../../lib/zod-utils.js';
const ClientUpsert = z.object({
kind: z.enum(['ul', 'ip', 'fl']),
name: z.string().min(1).max(500),
inn: z
.string()
.regex(/^\d{10}$|^\d{12}$/)
.nullable(),
kpp: z.string().regex(/^\d{9}$/).nullable(),
address: z.string().max(1000).nullable(),
email: z.string().email().nullable(),
phone: z.string().max(50).nullable(),
contactPerson: z.string().max(200).nullable(),
// Все необязательные поля: «пустая строка» → null до валидации (см. zod-utils.ts)
inn: optionalRegex(/^\d{10}$|^\d{12}$/),
kpp: optionalRegex(/^\d{9}$/),
address: optionalText(1000),
email: optionalEmail(),
phone: optionalText(50),
contactPerson: optionalText(200),
});
const ListQuery = z.object({
+9 -8
View File
@@ -2,18 +2,19 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
const OrgUpdate = z.object({
name: z.string().min(1).max(500),
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
kpp: z.string().regex(/^\d{9}$/).nullable(),
ogrn: z.string().regex(/^\d{13}$|^\d{15}$/).nullable(),
legalAddress: z.string().max(1000).nullable(),
bankName: z.string().max(500).nullable(),
bankBik: z.string().regex(/^\d{9}$/).nullable(),
bankAccount: z.string().regex(/^\d{20}$/).nullable(),
signatoryName: z.string().max(500).nullable(),
signatoryPosition: z.string().max(500).nullable(),
kpp: optionalRegex(/^\d{9}$/),
ogrn: optionalRegex(/^\d{13}$|^\d{15}$/),
legalAddress: optionalText(1000),
bankName: optionalText(500),
bankBik: optionalRegex(/^\d{9}$/),
bankAccount: optionalRegex(/^\d{20}$/),
signatoryName: optionalText(500),
signatoryPosition: optionalText(500),
});
export async function organizationsRoutes(app: FastifyInstance) {
+2 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { prisma } from '../../db.js';
import { getOrganizationId } from '../../lib/org.js';
import { optionalText } from '../../lib/zod-utils.js';
const VatRate = z.enum(['none', 'vat_0', 'vat_5', 'vat_7', 'vat_10', 'vat_20']);
@@ -10,7 +11,7 @@ const ServiceUpsert = z.object({
unit: z.string().min(1).max(50),
defaultPriceCents: z.coerce.number().int().nonnegative(),
defaultVat: VatRate.default('none'),
notes: z.string().max(2000).nullable(),
notes: optionalText(2000),
});
const ListQuery = z.object({
+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);
}