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:
@@ -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());
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user