import { z } from 'zod'; /** * Common reusable Zod schemas using Zod v4 features * These base schemas reduce repetition and improve maintainability */ // ============================================================================ // Base Field Schemas // ============================================================================ /** * UUID/ID field - used across all entities */ export const idSchema = z.string().min(1).describe('Unique identifier'); /** * Name field - used across all entities */ export const nameSchema = z.string().min(1).describe('Name'); /** * Optional URL field that accepts absolute URLs, relative paths, or empty string * Uses Zod v4's improved error handling * Accepts: * - Absolute URLs (http://, https://) * - Relative paths (starting with /) * - Empty strings * - null/undefined */ export const optionalUrlSchema = z .union([ z.string().refine( (val) => { if (val === '') return true; // Check if it's a valid absolute URL try { new URL(val); return true; } catch { // If not a valid URL, check if it's a relative path return val.startsWith('/'); } }, { message: 'Must be a valid URL, relative path starting with /, or empty string' } ), z.null(), ]) .optional() .describe('URL or path (can be absolute URL, relative path, empty string, null, or undefined)'); /** * Coordinate validation - latitude */ export const latitudeSchema = z .number() .min(-90, { message: 'Latitude must be between -90 and 90' }) .max(90, { message: 'Latitude must be between -90 and 90' }) .describe('Latitude in decimal degrees'); /** * Coordinate validation - longitude */ export const longitudeSchema = z .number() .min(-180, { message: 'Longitude must be between -180 and 180' }) .max(180, { message: 'Longitude must be between -180 and 180' }) .describe('Longitude in decimal degrees'); /** * Coordinate pair schema */ export const coordinateSchema = z .object({ lat: latitudeSchema, lng: longitudeSchema, }) .describe('Geographic coordinates'); /** * ISO 8601 timestamp string (RFC3339 format from Go backend) */ export const timestampSchema = z.string().refine((val) => { // Accept ISO datetime strings with optional timezone offset return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}:\d{2}|Z)?$/.test(val); }).optional().describe('ISO 8601 timestamp'); /** * Positive number schema */ export const positiveNumberSchema = z .number() .positive({ message: 'Must be a positive number' }) .describe('Positive number'); /** * Non-negative number schema */ export const nonNegativeNumberSchema = z .number() .nonnegative({ message: 'Must be a non-negative number' }) .describe('Non-negative number'); // ============================================================================ // Base Entity Schemas (Backend-aligned) // ============================================================================ /** * Base schema for backend entities with common fields * Uses Zod v4's .extend() for composition */ export const baseBackendEntitySchema = z.object({ ID: idSchema, CreatedAt: timestampSchema, UpdatedAt: timestampSchema, }); /** * Base schema for backend entities with name */ export const namedBackendEntitySchema = baseBackendEntitySchema.extend({ Name: nameSchema, }); /** * Base schema for request entities (snake_case) */ export const baseRequestEntitySchema = z.object({ id: idSchema.optional(), }); // ============================================================================ // Common Validation Patterns // ============================================================================ /** * Email validation with empty string fallback */ export const optionalEmailSchema = z .string() .email({ message: 'Please enter a valid email address' }) .or(z.literal('')) .optional() .describe('Email address (can be empty)'); /** * Phone number validation (Russian format) */ export const phoneSchema = z .string() .regex(/^((\+7|7|8)+([0-9()\-\s]){10,15})?$/, { message: 'Please enter a valid phone number', }) .or(z.literal('')) .optional() .describe('Phone number (Russian format)'); /** * Year validation (for founding year, etc.) */ export const yearSchema = z.coerce .number() .int() .min(1800, { message: 'Please enter a valid year' }) .max(new Date().getFullYear(), { message: 'The year cannot be in the future' }) .describe('Year'); /** * Score validation (0-1 range) * Commonly used for compatibility scores, risk assessments, etc. */ export const scoreSchema = z .number() .min(0, { message: 'Score must be between 0 and 1' }) .max(1, { message: 'Score must be between 0 and 1' }) .describe('Score (0-1 range)'); // ============================================================================ // Schema Composition Helpers // ============================================================================ /** * Create a backend entity schema with common fields * Uses Zod v4's composition features */ export function createBackendEntitySchema(additionalFields: T) { return baseBackendEntitySchema.extend(additionalFields); } /** * Create a named backend entity schema */ export function createNamedBackendEntitySchema(additionalFields: T) { return namedBackendEntitySchema.extend(additionalFields); } /** * Create a request schema with common patterns */ export function createRequestSchema(additionalFields: T) { return baseRequestEntitySchema.extend(additionalFields); } // ============================================================================ // Coordinate Utilities // ============================================================================ /** * Validate and normalize coordinates * Returns normalized [lat, lng] or null if invalid */ export function validateCoordinates(lat: unknown, lng: unknown): [number, number] | null { const result = coordinateSchema.safeParse({ lat, lng }); return result.success ? [result.data.lat, result.data.lng] : null; } /** * Check if coordinates are within bounds */ export function areCoordinatesInBounds( lat: number, lng: number, bounds: { north: number; south: number; east: number; west: number } ): boolean { const coordResult = coordinateSchema.safeParse({ lat, lng }); if (!coordResult.success) return false; const { lat: validLat, lng: validLng } = coordResult.data; // Handle longitude wrapping const lngInBounds = (bounds.west <= bounds.east && validLng >= bounds.west && validLng <= bounds.east) || (bounds.west > bounds.east && (validLng >= bounds.west || validLng <= bounds.east)); return validLat >= bounds.south && validLat <= bounds.north && lngInBounds; }