diff --git a/packages/shared/package-lock.json b/packages/shared/package-lock.json new file mode 100644 index 0000000..0626cd9 --- /dev/null +++ b/packages/shared/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "@padel-pro/shared", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@padel-pro/shared", + "version": "0.1.0", + "dependencies": { + "zod": "^3.22.4" + }, + "devDependencies": { + "typescript": "^5.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..2366022 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,17 @@ +{ + "name": "@padel-pro/shared", + "version": "0.1.0", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "type-check": "tsc --noEmit", + "lint": "eslint src/" + }, + "dependencies": { + "zod": "^3.22.4" + }, + "devDependencies": { + "typescript": "^5.3.3" + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..dc6be05 --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,5 @@ +// Re-export all types +export * from './types/index'; + +// Re-export all validations +export * from './validations/index'; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts new file mode 100644 index 0000000..8de861e --- /dev/null +++ b/packages/shared/src/types/index.ts @@ -0,0 +1,531 @@ +// ========================================== +// Enums +// ========================================== + +export type UserRole = 'super_admin' | 'org_admin' | 'site_admin' | 'staff' | 'coach' | 'client'; + +export type CourtType = 'indoor' | 'outdoor' | 'covered'; + +export type CourtStatus = 'active' | 'maintenance' | 'inactive'; + +export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'completed' | 'no_show'; + +export type PaymentType = 'cash' | 'card' | 'transfer' | 'membership' | 'pending'; + +export type MembershipStatus = 'active' | 'expired' | 'cancelled' | 'suspended'; + +export type TournamentType = 'single_elimination' | 'double_elimination' | 'round_robin' | 'swiss'; + +export type TournamentStatus = 'draft' | 'registration_open' | 'registration_closed' | 'in_progress' | 'completed' | 'cancelled'; + +export type MatchStatus = 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'walkover'; + +export type CashRegisterStatus = 'open' | 'closed'; + +// ========================================== +// Core Interfaces +// ========================================== + +export interface Organization { + id: string; + name: string; + slug: string; + logo?: string; + primaryColor?: string; + secondaryColor?: string; + email?: string; + phone?: string; + address?: string; + taxId?: string; + settings: OrganizationSettings; + createdAt: Date; + updatedAt: Date; +} + +export interface OrganizationSettings { + timezone: string; + currency: string; + dateFormat: string; + timeFormat: '12h' | '24h'; + defaultBookingDuration: number; // in minutes + cancellationPolicy: { + allowCancellation: boolean; + hoursBeforeBooking: number; + refundPercentage: number; + }; + bookingRules: { + minAdvanceBooking: number; // hours + maxAdvanceBooking: number; // days + requirePayment: boolean; + }; +} + +export interface Site { + id: string; + organizationId: string; + name: string; + slug: string; + address: string; + city: string; + state?: string; + country: string; + postalCode?: string; + latitude?: number; + longitude?: number; + phone?: string; + email?: string; + openingHours: WeeklySchedule; + amenities: string[]; + images: string[]; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface WeeklySchedule { + monday: DaySchedule; + tuesday: DaySchedule; + wednesday: DaySchedule; + thursday: DaySchedule; + friday: DaySchedule; + saturday: DaySchedule; + sunday: DaySchedule; +} + +export interface DaySchedule { + isOpen: boolean; + openTime?: string; // HH:mm format + closeTime?: string; // HH:mm format + breaks?: TimeSlot[]; +} + +export interface TimeSlot { + startTime: string; // HH:mm format + endTime: string; // HH:mm format +} + +export interface Court { + id: string; + siteId: string; + name: string; + type: CourtType; + status: CourtStatus; + description?: string; + features: string[]; + hourlyRate: number; + peakHourlyRate?: number; + peakHours?: TimeSlot[]; + images: string[]; + position?: number; // for ordering + createdAt: Date; + updatedAt: Date; +} + +// ========================================== +// User & Client Interfaces +// ========================================== + +export interface User { + id: string; + email: string; + passwordHash?: string; + firstName: string; + lastName: string; + phone?: string; + avatar?: string; + role: UserRole; + organizationId?: string; + siteIds: string[]; // sites user has access to + permissions: string[]; + isActive: boolean; + emailVerified: boolean; + lastLoginAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface Client { + id: string; + userId?: string; // linked user account (optional) + organizationId: string; + firstName: string; + lastName: string; + email: string; + phone?: string; + dateOfBirth?: Date; + gender?: 'male' | 'female' | 'other' | 'prefer_not_to_say'; + avatar?: string; + address?: string; + city?: string; + postalCode?: string; + country?: string; + notes?: string; + tags: string[]; + preferredSiteId?: string; + skillLevel?: 'beginner' | 'intermediate' | 'advanced' | 'professional'; + emergencyContact?: EmergencyContact; + marketingConsent: boolean; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface EmergencyContact { + name: string; + relationship: string; + phone: string; +} + +// ========================================== +// Booking Interfaces +// ========================================== + +export interface Booking { + id: string; + courtId: string; + siteId: string; + organizationId: string; + clientId: string; + createdByUserId?: string; + startTime: Date; + endTime: Date; + duration: number; // in minutes + status: BookingStatus; + paymentType: PaymentType; + paymentStatus: 'pending' | 'paid' | 'refunded' | 'partial'; + amount: number; + paidAmount: number; + currency: string; + participants: BookingParticipant[]; + notes?: string; + internalNotes?: string; + isRecurring: boolean; + recurringId?: string; + cancellationReason?: string; + cancelledAt?: Date; + cancelledByUserId?: string; + checkInAt?: Date; + checkOutAt?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface BookingParticipant { + clientId?: string; + name: string; + email?: string; + phone?: string; + isPayer: boolean; +} + +export interface RecurringBooking { + id: string; + courtId: string; + siteId: string; + organizationId: string; + clientId: string; + createdByUserId?: string; + dayOfWeek: number; // 0-6 (Sunday-Saturday) + startTime: string; // HH:mm format + endTime: string; // HH:mm format + startDate: Date; + endDate?: Date; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +// ========================================== +// Membership Interfaces +// ========================================== + +export interface MembershipPlan { + id: string; + organizationId: string; + siteIds: string[]; // applicable sites + name: string; + description?: string; + durationMonths: number; + price: number; + currency: string; + benefits: MembershipBenefit[]; + bookingDiscount?: number; // percentage + maxActiveBookings?: number; + priorityBooking: boolean; + guestPasses?: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface MembershipBenefit { + type: 'discount' | 'free_hours' | 'priority' | 'guest_pass' | 'product_discount' | 'custom'; + description: string; + value?: number; + conditions?: string; +} + +export interface Membership { + id: string; + clientId: string; + planId: string; + organizationId: string; + startDate: Date; + endDate: Date; + status: MembershipStatus; + autoRenew: boolean; + paymentMethod?: string; + remainingGuestPasses: number; + usedHours: number; + notes?: string; + suspendedAt?: Date; + suspensionReason?: string; + cancelledAt?: Date; + cancellationReason?: string; + createdAt: Date; + updatedAt: Date; +} + +// ========================================== +// Product & Sales Interfaces +// ========================================== + +export interface ProductCategory { + id: string; + organizationId: string; + name: string; + description?: string; + parentId?: string; + position: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface Product { + id: string; + organizationId: string; + siteIds: string[]; // available at these sites + categoryId?: string; + name: string; + description?: string; + sku?: string; + barcode?: string; + price: number; + cost?: number; + currency: string; + taxRate: number; + trackInventory: boolean; + images: string[]; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface ProductInventory { + id: string; + productId: string; + siteId: string; + quantity: number; + minQuantity?: number; + location?: string; + updatedAt: Date; +} + +export interface Sale { + id: string; + siteId: string; + organizationId: string; + clientId?: string; + userId: string; // staff who made the sale + cashRegisterId?: string; + items: SaleItem[]; + subtotal: number; + taxAmount: number; + discountAmount: number; + total: number; + currency: string; + paymentType: PaymentType; + paymentReference?: string; + notes?: string; + voidedAt?: Date; + voidedByUserId?: string; + voidReason?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface SaleItem { + productId?: string; + bookingId?: string; + membershipId?: string; + description: string; + quantity: number; + unitPrice: number; + taxRate: number; + discountAmount: number; + total: number; +} + +export interface CashRegister { + id: string; + siteId: string; + name: string; + status: CashRegisterStatus; + openedAt?: Date; + openedByUserId?: string; + openingBalance?: number; + closedAt?: Date; + closedByUserId?: string; + closingBalance?: number; + expectedBalance?: number; + notes?: string; + createdAt: Date; + updatedAt: Date; +} + +// ========================================== +// Tournament Interfaces +// ========================================== + +export interface Tournament { + id: string; + siteId: string; + organizationId: string; + name: string; + description?: string; + type: TournamentType; + status: TournamentStatus; + startDate: Date; + endDate: Date; + registrationStartDate: Date; + registrationEndDate: Date; + maxTeams: number; + minTeams: number; + teamSize: number; // players per team (1 for singles, 2 for doubles) + entryFee: number; + currency: string; + prizePool?: number; + prizes?: TournamentPrize[]; + rules?: string; + courtIds: string[]; + categories: TournamentCategory[]; + images: string[]; + isPublic: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface TournamentCategory { + id: string; + name: string; + description?: string; + minSkillLevel?: string; + maxSkillLevel?: string; + minAge?: number; + maxAge?: number; + gender?: 'male' | 'female' | 'mixed' | 'any'; +} + +export interface TournamentPrize { + position: number; + description: string; + amount?: number; + currency?: string; +} + +export interface TournamentTeam { + id: string; + tournamentId: string; + categoryId: string; + name: string; + players: TournamentPlayer[]; + seed?: number; + status: 'registered' | 'confirmed' | 'withdrawn' | 'disqualified'; + paidAt?: Date; + notes?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface TournamentPlayer { + clientId: string; + firstName: string; + lastName: string; + email?: string; + phone?: string; + isCaptain: boolean; +} + +export interface TournamentMatch { + id: string; + tournamentId: string; + categoryId: string; + round: number; + matchNumber: number; + courtId?: string; + scheduledAt?: Date; + team1Id?: string; + team2Id?: string; + winnerId?: string; + score?: MatchScore; + status: MatchStatus; + notes?: string; + createdAt: Date; + updatedAt: Date; +} + +export interface MatchScore { + sets: SetScore[]; + winner: 'team1' | 'team2'; + duration?: number; // in minutes +} + +export interface SetScore { + team1Score: number; + team2Score: number; + tiebreak?: TiebreakScore; +} + +export interface TiebreakScore { + team1Score: number; + team2Score: number; +} + +// ========================================== +// Utility Types +// ========================================== + +export type WithTimestamps = T & { + createdAt: Date; + updatedAt: Date; +}; + +export type CreateInput = Omit; + +export type UpdateInput = Partial>; + +export type PaginationParams = { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +}; + +export type PaginatedResponse = { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +}; + +export type ApiResponse = { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: Record; + }; +}; diff --git a/packages/shared/src/validations/index.ts b/packages/shared/src/validations/index.ts new file mode 100644 index 0000000..bc8fa0f --- /dev/null +++ b/packages/shared/src/validations/index.ts @@ -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; + +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; + +export const forgotPasswordSchema = z.object({ + email: emailSchema, +}); + +export type ForgotPasswordInput = z.infer; + +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; + +// ========================================== +// 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; + +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; + +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; + +// ========================================== +// 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; + +export const updateSiteSchema = createSiteSchema.partial(); + +export type UpdateSiteInput = z.infer; + +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; + +export const updateCourtSchema = createCourtSchema.partial().omit({ siteId: true }); + +export type UpdateCourtInput = z.infer; + +// ========================================== +// 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; + +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; + +export const updateProductSchema = createProductSchema.partial(); + +export type UpdateProductInput = z.infer; + +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; + +// ========================================== +// 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; + +// 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; + +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; + +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; + +// ========================================== +// 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; + +export const updateMembershipPlanSchema = createMembershipPlanSchema.partial(); + +export type UpdateMembershipPlanInput = z.infer; + +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; + +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; + +// ========================================== +// 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; + +export const updateClientSchema = createClientSchema.partial(); + +export type UpdateClientInput = z.infer; + +// ========================================== +// 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; + +export const updateOrganizationSchema = createOrganizationSchema.partial().omit({ slug: true }); + +export type UpdateOrganizationInput = z.infer; + +// ========================================== +// 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; + +export const updateUserSchema = createUserSchema.partial().omit({ password: true }); + +export type UpdateUserInput = z.infer; + +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; + +// ========================================== +// 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; + +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; diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..b146cdb --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}