feat(shared): add types and Zod validations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ivan
2026-02-01 06:15:04 +00:00
parent 039c17352b
commit b042e14fc7
6 changed files with 1188 additions and 0 deletions

View File

@@ -0,0 +1,575 @@
import { z } from 'zod';
// ==========================================
// Common Schemas
// ==========================================
export const emailSchema = z.string().email('Invalid email address');
export const phoneSchema = z.string().regex(
/^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/,
'Invalid phone number'
).optional();
export const passwordSchema = z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number');
export const timeSchema = z.string().regex(
/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/,
'Invalid time format (HH:mm)'
);
export const slugSchema = z
.string()
.min(2, 'Slug must be at least 2 characters')
.max(50, 'Slug must be at most 50 characters')
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase and contain only letters, numbers, and hyphens');
// ==========================================
// Auth Schemas
// ==========================================
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'Password is required'),
rememberMe: z.boolean().optional().default(false),
});
export type LoginInput = z.infer<typeof loginSchema>;
export const registerClientSchema = z.object({
email: emailSchema,
password: passwordSchema,
confirmPassword: z.string(),
firstName: z.string().min(1, 'First name is required').max(50),
lastName: z.string().min(1, 'Last name is required').max(50),
phone: phoneSchema,
dateOfBirth: z.coerce.date().optional(),
gender: z.enum(['male', 'female', 'other', 'prefer_not_to_say']).optional(),
marketingConsent: z.boolean().optional().default(false),
termsAccepted: z.boolean().refine(val => val === true, {
message: 'You must accept the terms and conditions',
}),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export type RegisterClientInput = z.infer<typeof registerClientSchema>;
export const forgotPasswordSchema = z.object({
email: emailSchema,
});
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Reset token is required'),
password: passwordSchema,
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
// ==========================================
// Booking Schemas
// ==========================================
export const createBookingSchema = z.object({
courtId: z.string().uuid('Invalid court ID'),
clientId: z.string().uuid('Invalid client ID'),
startTime: z.coerce.date(),
endTime: z.coerce.date(),
participants: z.array(z.object({
clientId: z.string().uuid().optional(),
name: z.string().min(1, 'Participant name is required'),
email: emailSchema.optional(),
phone: phoneSchema,
isPayer: z.boolean().default(false),
})).optional().default([]),
paymentType: z.enum(['cash', 'card', 'transfer', 'membership', 'pending']).default('pending'),
notes: z.string().max(500).optional(),
}).refine(data => data.endTime > data.startTime, {
message: 'End time must be after start time',
path: ['endTime'],
});
export type CreateBookingInput = z.infer<typeof createBookingSchema>;
export const updateBookingStatusSchema = z.object({
bookingId: z.string().uuid('Invalid booking ID'),
status: z.enum(['pending', 'confirmed', 'cancelled', 'completed', 'no_show']),
cancellationReason: z.string().max(500).optional(),
});
export type UpdateBookingStatusInput = z.infer<typeof updateBookingStatusSchema>;
export const createRecurringBookingSchema = z.object({
courtId: z.string().uuid('Invalid court ID'),
clientId: z.string().uuid('Invalid client ID'),
dayOfWeek: z.number().int().min(0).max(6),
startTime: timeSchema,
endTime: timeSchema,
startDate: z.coerce.date(),
endDate: z.coerce.date().optional(),
paymentType: z.enum(['cash', 'card', 'transfer', 'membership', 'pending']).default('pending'),
notes: z.string().max(500).optional(),
}).refine(data => {
const [startH, startM] = data.startTime.split(':').map(Number);
const [endH, endM] = data.endTime.split(':').map(Number);
return endH * 60 + endM > startH * 60 + startM;
}, {
message: 'End time must be after start time',
path: ['endTime'],
});
export type CreateRecurringBookingInput = z.infer<typeof createRecurringBookingSchema>;
// ==========================================
// Site & Court Schemas
// ==========================================
const dayScheduleSchema = z.object({
isOpen: z.boolean(),
openTime: timeSchema.optional(),
closeTime: timeSchema.optional(),
breaks: z.array(z.object({
startTime: timeSchema,
endTime: timeSchema,
})).optional(),
}).refine(data => {
if (data.isOpen) {
return data.openTime !== undefined && data.closeTime !== undefined;
}
return true;
}, {
message: 'Open and close times are required when the day is open',
});
const weeklyScheduleSchema = z.object({
monday: dayScheduleSchema,
tuesday: dayScheduleSchema,
wednesday: dayScheduleSchema,
thursday: dayScheduleSchema,
friday: dayScheduleSchema,
saturday: dayScheduleSchema,
sunday: dayScheduleSchema,
});
export const createSiteSchema = z.object({
name: z.string().min(1, 'Site name is required').max(100),
slug: slugSchema,
address: z.string().min(1, 'Address is required').max(200),
city: z.string().min(1, 'City is required').max(100),
state: z.string().max(100).optional(),
country: z.string().min(1, 'Country is required').max(100),
postalCode: z.string().max(20).optional(),
latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(),
phone: phoneSchema,
email: emailSchema.optional(),
openingHours: weeklyScheduleSchema,
amenities: z.array(z.string()).optional().default([]),
images: z.array(z.string().url()).optional().default([]),
isActive: z.boolean().optional().default(true),
});
export type CreateSiteInput = z.infer<typeof createSiteSchema>;
export const updateSiteSchema = createSiteSchema.partial();
export type UpdateSiteInput = z.infer<typeof updateSiteSchema>;
export const createCourtSchema = z.object({
siteId: z.string().uuid('Invalid site ID'),
name: z.string().min(1, 'Court name is required').max(50),
type: z.enum(['indoor', 'outdoor', 'covered']),
status: z.enum(['active', 'maintenance', 'inactive']).default('active'),
description: z.string().max(500).optional(),
features: z.array(z.string()).optional().default([]),
hourlyRate: z.number().positive('Hourly rate must be positive'),
peakHourlyRate: z.number().positive().optional(),
peakHours: z.array(z.object({
startTime: timeSchema,
endTime: timeSchema,
})).optional(),
images: z.array(z.string().url()).optional().default([]),
position: z.number().int().min(0).optional(),
});
export type CreateCourtInput = z.infer<typeof createCourtSchema>;
export const updateCourtSchema = createCourtSchema.partial().omit({ siteId: true });
export type UpdateCourtInput = z.infer<typeof updateCourtSchema>;
// ==========================================
// Product & Sales Schemas
// ==========================================
export const createProductCategorySchema = z.object({
name: z.string().min(1, 'Category name is required').max(100),
description: z.string().max(500).optional(),
parentId: z.string().uuid().optional(),
position: z.number().int().min(0).optional().default(0),
isActive: z.boolean().optional().default(true),
});
export type CreateProductCategoryInput = z.infer<typeof createProductCategorySchema>;
export const createProductSchema = z.object({
categoryId: z.string().uuid().optional(),
siteIds: z.array(z.string().uuid()).min(1, 'At least one site is required'),
name: z.string().min(1, 'Product name is required').max(100),
description: z.string().max(1000).optional(),
sku: z.string().max(50).optional(),
barcode: z.string().max(50).optional(),
price: z.number().nonnegative('Price must be non-negative'),
cost: z.number().nonnegative().optional(),
currency: z.string().length(3, 'Currency must be a 3-letter code').default('EUR'),
taxRate: z.number().min(0).max(100).default(21),
trackInventory: z.boolean().optional().default(false),
images: z.array(z.string().url()).optional().default([]),
isActive: z.boolean().optional().default(true),
});
export type CreateProductInput = z.infer<typeof createProductSchema>;
export const updateProductSchema = createProductSchema.partial();
export type UpdateProductInput = z.infer<typeof updateProductSchema>;
const saleItemSchema = z.object({
productId: z.string().uuid().optional(),
bookingId: z.string().uuid().optional(),
membershipId: z.string().uuid().optional(),
description: z.string().min(1, 'Item description is required').max(200),
quantity: z.number().int().positive('Quantity must be positive'),
unitPrice: z.number().nonnegative('Unit price must be non-negative'),
taxRate: z.number().min(0).max(100).default(21),
discountAmount: z.number().nonnegative().optional().default(0),
});
export const createSaleSchema = z.object({
siteId: z.string().uuid('Invalid site ID'),
clientId: z.string().uuid().optional(),
cashRegisterId: z.string().uuid().optional(),
items: z.array(saleItemSchema).min(1, 'At least one item is required'),
discountAmount: z.number().nonnegative().optional().default(0),
paymentType: z.enum(['cash', 'card', 'transfer', 'membership', 'pending']),
paymentReference: z.string().max(100).optional(),
notes: z.string().max(500).optional(),
});
export type CreateSaleInput = z.infer<typeof createSaleSchema>;
// ==========================================
// Tournament Schemas
// ==========================================
const tournamentCategorySchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Category name is required').max(50),
description: z.string().max(200).optional(),
minSkillLevel: z.enum(['beginner', 'intermediate', 'advanced', 'professional']).optional(),
maxSkillLevel: z.enum(['beginner', 'intermediate', 'advanced', 'professional']).optional(),
minAge: z.number().int().min(0).max(100).optional(),
maxAge: z.number().int().min(0).max(100).optional(),
gender: z.enum(['male', 'female', 'mixed', 'any']).optional(),
});
const tournamentPrizeSchema = z.object({
position: z.number().int().positive(),
description: z.string().min(1).max(200),
amount: z.number().nonnegative().optional(),
currency: z.string().length(3).optional(),
});
export const createTournamentSchema = z.object({
siteId: z.string().uuid('Invalid site ID'),
name: z.string().min(1, 'Tournament name is required').max(100),
description: z.string().max(2000).optional(),
type: z.enum(['single_elimination', 'double_elimination', 'round_robin', 'swiss']),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
registrationStartDate: z.coerce.date(),
registrationEndDate: z.coerce.date(),
maxTeams: z.number().int().min(2, 'Minimum 2 teams required').max(256),
minTeams: z.number().int().min(2, 'Minimum 2 teams required').default(2),
teamSize: z.number().int().min(1).max(4).default(2),
entryFee: z.number().nonnegative().default(0),
currency: z.string().length(3, 'Currency must be a 3-letter code').default('EUR'),
prizePool: z.number().nonnegative().optional(),
prizes: z.array(tournamentPrizeSchema).optional(),
rules: z.string().max(5000).optional(),
courtIds: z.array(z.string().uuid()).min(1, 'At least one court is required'),
categories: z.array(tournamentCategorySchema).min(1, 'At least one category is required'),
images: z.array(z.string().url()).optional().default([]),
isPublic: z.boolean().optional().default(true),
}).refine(data => data.endDate >= data.startDate, {
message: 'End date must be on or after start date',
path: ['endDate'],
}).refine(data => data.registrationEndDate >= data.registrationStartDate, {
message: 'Registration end date must be on or after registration start date',
path: ['registrationEndDate'],
}).refine(data => data.registrationEndDate <= data.startDate, {
message: 'Registration must end before or on tournament start date',
path: ['registrationEndDate'],
}).refine(data => data.minTeams <= data.maxTeams, {
message: 'Minimum teams cannot exceed maximum teams',
path: ['minTeams'],
});
export type CreateTournamentInput = z.infer<typeof createTournamentSchema>;
// Base tournament schema without refinements for partial updates
const baseTournamentSchema = z.object({
name: z.string().min(1, 'Tournament name is required').max(100),
description: z.string().max(2000).optional(),
type: z.enum(['single_elimination', 'double_elimination', 'round_robin', 'swiss']),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
registrationStartDate: z.coerce.date(),
registrationEndDate: z.coerce.date(),
maxTeams: z.number().int().min(2, 'Minimum 2 teams required').max(256),
minTeams: z.number().int().min(2, 'Minimum 2 teams required').default(2),
teamSize: z.number().int().min(1).max(4).default(2),
entryFee: z.number().nonnegative().default(0),
currency: z.string().length(3, 'Currency must be a 3-letter code').default('EUR'),
prizePool: z.number().nonnegative().optional(),
prizes: z.array(tournamentPrizeSchema).optional(),
rules: z.string().max(5000).optional(),
courtIds: z.array(z.string().uuid()).min(1, 'At least one court is required'),
categories: z.array(tournamentCategorySchema).min(1, 'At least one category is required'),
images: z.array(z.string().url()).optional().default([]),
isPublic: z.boolean().optional().default(true),
status: z.enum(['draft', 'registration_open', 'registration_closed', 'in_progress', 'completed', 'cancelled']).optional(),
});
export const updateTournamentSchema = baseTournamentSchema.partial();
export type UpdateTournamentInput = z.infer<typeof updateTournamentSchema>;
export const registerTournamentTeamSchema = z.object({
tournamentId: z.string().uuid('Invalid tournament ID'),
categoryId: z.string().uuid('Invalid category ID'),
name: z.string().min(1, 'Team name is required').max(50),
players: z.array(z.object({
clientId: z.string().uuid('Invalid client ID'),
firstName: z.string().min(1).max(50),
lastName: z.string().min(1).max(50),
email: emailSchema.optional(),
phone: phoneSchema,
isCaptain: z.boolean().default(false),
})).min(1, 'At least one player is required'),
});
export type RegisterTournamentTeamInput = z.infer<typeof registerTournamentTeamSchema>;
export const updateMatchScoreSchema = z.object({
matchId: z.string().uuid('Invalid match ID'),
score: z.object({
sets: z.array(z.object({
team1Score: z.number().int().min(0),
team2Score: z.number().int().min(0),
tiebreak: z.object({
team1Score: z.number().int().min(0),
team2Score: z.number().int().min(0),
}).optional(),
})).min(1, 'At least one set is required'),
winner: z.enum(['team1', 'team2']),
duration: z.number().int().positive().optional(),
}),
});
export type UpdateMatchScoreInput = z.infer<typeof updateMatchScoreSchema>;
// ==========================================
// Membership Schemas
// ==========================================
const membershipBenefitSchema = z.object({
type: z.enum(['discount', 'free_hours', 'priority', 'guest_pass', 'product_discount', 'custom']),
description: z.string().min(1, 'Benefit description is required').max(200),
value: z.number().optional(),
conditions: z.string().max(500).optional(),
});
export const createMembershipPlanSchema = z.object({
siteIds: z.array(z.string().uuid()).min(1, 'At least one site is required'),
name: z.string().min(1, 'Plan name is required').max(100),
description: z.string().max(1000).optional(),
durationMonths: z.number().int().positive('Duration must be at least 1 month'),
price: z.number().nonnegative('Price must be non-negative'),
currency: z.string().length(3, 'Currency must be a 3-letter code').default('EUR'),
benefits: z.array(membershipBenefitSchema).optional().default([]),
bookingDiscount: z.number().min(0).max(100).optional(),
maxActiveBookings: z.number().int().positive().optional(),
priorityBooking: z.boolean().optional().default(false),
guestPasses: z.number().int().nonnegative().optional(),
isActive: z.boolean().optional().default(true),
});
export type CreateMembershipPlanInput = z.infer<typeof createMembershipPlanSchema>;
export const updateMembershipPlanSchema = createMembershipPlanSchema.partial();
export type UpdateMembershipPlanInput = z.infer<typeof updateMembershipPlanSchema>;
export const createMembershipSchema = z.object({
clientId: z.string().uuid('Invalid client ID'),
planId: z.string().uuid('Invalid plan ID'),
startDate: z.coerce.date(),
autoRenew: z.boolean().optional().default(false),
paymentMethod: z.string().max(50).optional(),
notes: z.string().max(500).optional(),
});
export type CreateMembershipInput = z.infer<typeof createMembershipSchema>;
export const updateMembershipStatusSchema = z.object({
membershipId: z.string().uuid('Invalid membership ID'),
status: z.enum(['active', 'expired', 'cancelled', 'suspended']),
reason: z.string().max(500).optional(),
});
export type UpdateMembershipStatusInput = z.infer<typeof updateMembershipStatusSchema>;
// ==========================================
// Client Schemas
// ==========================================
export const createClientSchema = z.object({
firstName: z.string().min(1, 'First name is required').max(50),
lastName: z.string().min(1, 'Last name is required').max(50),
email: emailSchema,
phone: phoneSchema,
dateOfBirth: z.coerce.date().optional(),
gender: z.enum(['male', 'female', 'other', 'prefer_not_to_say']).optional(),
address: z.string().max(200).optional(),
city: z.string().max(100).optional(),
postalCode: z.string().max(20).optional(),
country: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
tags: z.array(z.string()).optional().default([]),
preferredSiteId: z.string().uuid().optional(),
skillLevel: z.enum(['beginner', 'intermediate', 'advanced', 'professional']).optional(),
emergencyContact: z.object({
name: z.string().min(1).max(100),
relationship: z.string().min(1).max(50),
phone: z.string().min(1),
}).optional(),
marketingConsent: z.boolean().optional().default(false),
});
export type CreateClientInput = z.infer<typeof createClientSchema>;
export const updateClientSchema = createClientSchema.partial();
export type UpdateClientInput = z.infer<typeof updateClientSchema>;
// ==========================================
// Organization Schemas
// ==========================================
export const createOrganizationSchema = z.object({
name: z.string().min(1, 'Organization name is required').max(100),
slug: slugSchema,
logo: z.string().url().optional(),
primaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color format').optional(),
secondaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid color format').optional(),
email: emailSchema.optional(),
phone: phoneSchema,
address: z.string().max(200).optional(),
taxId: z.string().max(50).optional(),
settings: z.object({
timezone: z.string().default('Europe/Madrid'),
currency: z.string().length(3).default('EUR'),
dateFormat: z.string().default('DD/MM/YYYY'),
timeFormat: z.enum(['12h', '24h']).default('24h'),
defaultBookingDuration: z.number().int().positive().default(90),
cancellationPolicy: z.object({
allowCancellation: z.boolean().default(true),
hoursBeforeBooking: z.number().int().nonnegative().default(24),
refundPercentage: z.number().min(0).max(100).default(100),
}).optional(),
bookingRules: z.object({
minAdvanceBooking: z.number().int().nonnegative().default(0),
maxAdvanceBooking: z.number().int().positive().default(14),
requirePayment: z.boolean().default(false),
}).optional(),
}).optional(),
});
export type CreateOrganizationInput = z.infer<typeof createOrganizationSchema>;
export const updateOrganizationSchema = createOrganizationSchema.partial().omit({ slug: true });
export type UpdateOrganizationInput = z.infer<typeof updateOrganizationSchema>;
// ==========================================
// User Schemas
// ==========================================
export const createUserSchema = z.object({
email: emailSchema,
password: passwordSchema,
firstName: z.string().min(1, 'First name is required').max(50),
lastName: z.string().min(1, 'Last name is required').max(50),
phone: phoneSchema,
role: z.enum(['super_admin', 'org_admin', 'site_admin', 'staff', 'coach', 'client']),
organizationId: z.string().uuid().optional(),
siteIds: z.array(z.string().uuid()).optional().default([]),
permissions: z.array(z.string()).optional().default([]),
isActive: z.boolean().optional().default(true),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const updateUserSchema = createUserSchema.partial().omit({ password: true });
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: passwordSchema,
confirmPassword: z.string(),
}).refine(data => data.newPassword === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
}).refine(data => data.currentPassword !== data.newPassword, {
message: 'New password must be different from current password',
path: ['newPassword'],
});
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
// ==========================================
// Pagination & Filter Schemas
// ==========================================
export const paginationSchema = z.object({
page: z.coerce.number().int().positive().optional().default(1),
limit: z.coerce.number().int().positive().max(100).optional().default(20),
sortBy: z.string().optional(),
sortOrder: z.enum(['asc', 'desc']).optional().default('desc'),
});
export type PaginationInput = z.infer<typeof paginationSchema>;
export const dateRangeSchema = z.object({
startDate: z.coerce.date(),
endDate: z.coerce.date(),
}).refine(data => data.endDate >= data.startDate, {
message: 'End date must be on or after start date',
path: ['endDate'],
});
export type DateRangeInput = z.infer<typeof dateRangeSchema>;