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 { z } from 'zod';
|
||||||
import { prisma } from '../../db.js';
|
import { prisma } from '../../db.js';
|
||||||
import { getOrganizationId } from '../../lib/org.js';
|
import { getOrganizationId } from '../../lib/org.js';
|
||||||
|
import { optionalEmail, optionalRegex, optionalText } from '../../lib/zod-utils.js';
|
||||||
|
|
||||||
const ClientUpsert = z.object({
|
const ClientUpsert = z.object({
|
||||||
kind: z.enum(['ul', 'ip', 'fl']),
|
kind: z.enum(['ul', 'ip', 'fl']),
|
||||||
name: z.string().min(1).max(500),
|
name: z.string().min(1).max(500),
|
||||||
inn: z
|
// Все необязательные поля: «пустая строка» → null до валидации (см. zod-utils.ts)
|
||||||
.string()
|
inn: optionalRegex(/^\d{10}$|^\d{12}$/),
|
||||||
.regex(/^\d{10}$|^\d{12}$/)
|
kpp: optionalRegex(/^\d{9}$/),
|
||||||
.nullable(),
|
address: optionalText(1000),
|
||||||
kpp: z.string().regex(/^\d{9}$/).nullable(),
|
email: optionalEmail(),
|
||||||
address: z.string().max(1000).nullable(),
|
phone: optionalText(50),
|
||||||
email: z.string().email().nullable(),
|
contactPerson: optionalText(200),
|
||||||
phone: z.string().max(50).nullable(),
|
|
||||||
contactPerson: z.string().max(200).nullable(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ListQuery = z.object({
|
const ListQuery = z.object({
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { prisma } from '../../db.js';
|
import { prisma } from '../../db.js';
|
||||||
import { getOrganizationId } from '../../lib/org.js';
|
import { getOrganizationId } from '../../lib/org.js';
|
||||||
|
import { optionalRegex, optionalText } from '../../lib/zod-utils.js';
|
||||||
|
|
||||||
const OrgUpdate = z.object({
|
const OrgUpdate = z.object({
|
||||||
name: z.string().min(1).max(500),
|
name: z.string().min(1).max(500),
|
||||||
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
|
inn: z.string().regex(/^\d{10}$|^\d{12}$/),
|
||||||
kpp: z.string().regex(/^\d{9}$/).nullable(),
|
kpp: optionalRegex(/^\d{9}$/),
|
||||||
ogrn: z.string().regex(/^\d{13}$|^\d{15}$/).nullable(),
|
ogrn: optionalRegex(/^\d{13}$|^\d{15}$/),
|
||||||
legalAddress: z.string().max(1000).nullable(),
|
legalAddress: optionalText(1000),
|
||||||
bankName: z.string().max(500).nullable(),
|
bankName: optionalText(500),
|
||||||
bankBik: z.string().regex(/^\d{9}$/).nullable(),
|
bankBik: optionalRegex(/^\d{9}$/),
|
||||||
bankAccount: z.string().regex(/^\d{20}$/).nullable(),
|
bankAccount: optionalRegex(/^\d{20}$/),
|
||||||
signatoryName: z.string().max(500).nullable(),
|
signatoryName: optionalText(500),
|
||||||
signatoryPosition: z.string().max(500).nullable(),
|
signatoryPosition: optionalText(500),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function organizationsRoutes(app: FastifyInstance) {
|
export async function organizationsRoutes(app: FastifyInstance) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { prisma } from '../../db.js';
|
import { prisma } from '../../db.js';
|
||||||
import { getOrganizationId } from '../../lib/org.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']);
|
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),
|
unit: z.string().min(1).max(50),
|
||||||
defaultPriceCents: z.coerce.number().int().nonnegative(),
|
defaultPriceCents: z.coerce.number().int().nonnegative(),
|
||||||
defaultVat: VatRate.default('none'),
|
defaultVat: VatRate.default('none'),
|
||||||
notes: z.string().max(2000).nullable(),
|
notes: optionalText(2000),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ListQuery = z.object({
|
const ListQuery = z.object({
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ export class ApiError extends Error {
|
|||||||
constructor(public status: number, public code: string, public details?: unknown) {
|
constructor(public status: number, public code: string, public details?: unknown) {
|
||||||
super(`${status} ${code}`);
|
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> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function ClientsPage() {
|
|||||||
setEditing(null);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} 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());
|
setSavedAt(new Date());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
|
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -185,7 +185,7 @@ export function DocumentEditPage() {
|
|||||||
await api.del(`/api/documents/${savedId}`);
|
await api.del(`/api/documents/${savedId}`);
|
||||||
navigate('/documents');
|
navigate('/documents');
|
||||||
} catch (e) {
|
} 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);
|
setDraft(saved);
|
||||||
setSavedAt(new Date());
|
setSavedAt(new Date());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
|
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function ServicesPage() {
|
|||||||
setEditing(null);
|
setEditing(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} 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 });
|
await api.put(`/api/templates/${id}`, { name, docType, body });
|
||||||
setSavedAt(new Date());
|
setSavedAt(new Date());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? `${e.code} (${e.status})` : String(e));
|
setError(e instanceof ApiError ? e.prettyMessage() : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user