diff --git a/apps/api/src/lib/zod-utils.ts b/apps/api/src/lib/zod-utils.ts new file mode 100644 index 0000000..d2a5ca0 --- /dev/null +++ b/apps/api/src/lib/zod-utils.ts @@ -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>(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()); +} diff --git a/apps/api/src/modules/clients/routes.ts b/apps/api/src/modules/clients/routes.ts index 365751b..a0ea9dc 100644 --- a/apps/api/src/modules/clients/routes.ts +++ b/apps/api/src/modules/clients/routes.ts @@ -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({ diff --git a/apps/api/src/modules/organizations/routes.ts b/apps/api/src/modules/organizations/routes.ts index 0b85386..f06b1a3 100644 --- a/apps/api/src/modules/organizations/routes.ts +++ b/apps/api/src/modules/organizations/routes.ts @@ -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) { diff --git a/apps/api/src/modules/services/routes.ts b/apps/api/src/modules/services/routes.ts index 19d3bef..183e377 100644 --- a/apps/api/src/modules/services/routes.ts +++ b/apps/api/src/modules/services/routes.ts @@ -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({ diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 978cfec..4b6bc5f 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 } }; + 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(method: string, path: string, body?: unknown): Promise { diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index cfe6767..a3970fd 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -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)); } } diff --git a/apps/web/src/pages/DocumentEdit.tsx b/apps/web/src/pages/DocumentEdit.tsx index a264ec6..3ca0cdd 100644 --- a/apps/web/src/pages/DocumentEdit.tsx +++ b/apps/web/src/pages/DocumentEdit.tsx @@ -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)); } } diff --git a/apps/web/src/pages/Organization.tsx b/apps/web/src/pages/Organization.tsx index 9922fa8..74341fb 100644 --- a/apps/web/src/pages/Organization.tsx +++ b/apps/web/src/pages/Organization.tsx @@ -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); } diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx index 4e0f485..33174dd 100644 --- a/apps/web/src/pages/Services.tsx +++ b/apps/web/src/pages/Services.tsx @@ -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)); } } diff --git a/apps/web/src/pages/TemplateEdit.tsx b/apps/web/src/pages/TemplateEdit.tsx index 03db8b0..c74cbba 100644 --- a/apps/web/src/pages/TemplateEdit.tsx +++ b/apps/web/src/pages/TemplateEdit.tsx @@ -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); }