enhancment complete with phase 10
This commit is contained in:
1 parent
2c4927478a
commit
6adb541967
44 files changed
+7820
-845
No files matched your search
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* API Response Utilities
|
||||
*
|
||||
* Standardized response handling for FitAI Mobile App
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./responses";
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* API Response Helpers for FitAI Mobile App
|
||||
*
|
||||
* Utilities for handling API responses on the client side.
|
||||
*/
|
||||
|
||||
import {
|
||||
ApiResponse,
|
||||
ApiErrorResponse,
|
||||
ApiSuccessResponse,
|
||||
isSuccessResponse,
|
||||
isErrorResponse,
|
||||
ApiErrorCode,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Error thrown when API request fails
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code?: string,
|
||||
public details?: Record<string, string[]>,
|
||||
public status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ApiError from error response
|
||||
*/
|
||||
static fromResponse(response: ApiErrorResponse, status?: number): ApiError {
|
||||
return new ApiError(
|
||||
response.error.message,
|
||||
response.error.code,
|
||||
response.error.details,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a specific error code
|
||||
*/
|
||||
isCode(code: ApiErrorCode | string): boolean {
|
||||
return this.code === code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error message
|
||||
*/
|
||||
getUserMessage(): string {
|
||||
// Map error codes to user-friendly messages
|
||||
const userMessages: Record<string, string> = {
|
||||
[ApiErrorCode.UNAUTHORIZED]: "Please sign in to continue",
|
||||
[ApiErrorCode.FORBIDDEN]: "You don't have permission to do that",
|
||||
[ApiErrorCode.NOT_FOUND]: "Item not found",
|
||||
[ApiErrorCode.VALIDATION_ERROR]: "Please check your input and try again",
|
||||
[ApiErrorCode.NETWORK_ERROR]:
|
||||
"Connection failed. Please check your internet.",
|
||||
[ApiErrorCode.TIMEOUT]: "Request timed out. Please try again.",
|
||||
[ApiErrorCode.INTERNAL_ERROR]:
|
||||
"Something went wrong. Please try again later.",
|
||||
};
|
||||
|
||||
if (this.code && userMessages[this.code]) {
|
||||
return userMessages[this.code];
|
||||
}
|
||||
|
||||
return this.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first validation error message
|
||||
*/
|
||||
getFirstValidationError(): string | undefined {
|
||||
if (!this.details) return undefined;
|
||||
|
||||
for (const field in this.details) {
|
||||
const errors = this.details[field];
|
||||
if (errors && errors.length > 0) {
|
||||
return errors[0];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API response and throw error if not successful
|
||||
*
|
||||
* @param response - API response to handle
|
||||
* @returns Data from successful response
|
||||
* @throws ApiError if response is not successful
|
||||
*
|
||||
* @example
|
||||
* const data = handleResponse(await fetch('/api/users').then(r => r.json()));
|
||||
*/
|
||||
export function handleResponse<T>(
|
||||
response: ApiResponse<T>,
|
||||
status?: number,
|
||||
): T {
|
||||
if (isSuccessResponse(response)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
throw ApiError.fromResponse(response, status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely handle API response without throwing
|
||||
*
|
||||
* @param response - API response to handle
|
||||
* @returns Result object with data or error
|
||||
*
|
||||
* @example
|
||||
* const result = safeHandleResponse(await fetch('/api/users').then(r => r.json()));
|
||||
* if (result.success) {
|
||||
* console.log(result.data);
|
||||
* } else {
|
||||
* console.error(result.error);
|
||||
* }
|
||||
*/
|
||||
export function safeHandleResponse<T>(
|
||||
response: ApiResponse<T>,
|
||||
status?: number,
|
||||
): { success: true; data: T } | { success: false; error: ApiError } {
|
||||
if (isSuccessResponse(response)) {
|
||||
return { success: true, data: response.data };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: ApiError.fromResponse(response, status),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*
|
||||
* @param error - Error to extract message from
|
||||
* @returns Error message string
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.getUserMessage();
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
return "An unexpected error occurred";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is network related
|
||||
*/
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof ApiError) {
|
||||
return (
|
||||
error.code === ApiErrorCode.NETWORK_ERROR ||
|
||||
error.code === ApiErrorCode.TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.message.includes("network") ||
|
||||
error.message.includes("fetch") ||
|
||||
error.message.includes("timeout")
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is authentication related
|
||||
*/
|
||||
export function isAuthError(error: unknown): boolean {
|
||||
if (error instanceof ApiError) {
|
||||
return (
|
||||
error.code === ApiErrorCode.UNAUTHORIZED ||
|
||||
error.code === ApiErrorCode.INVALID_TOKEN ||
|
||||
error.status === 401
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is permission related
|
||||
*/
|
||||
export function isPermissionError(error: unknown): boolean {
|
||||
if (error instanceof ApiError) {
|
||||
return (
|
||||
error.code === ApiErrorCode.FORBIDDEN ||
|
||||
error.code === ApiErrorCode.INSUFFICIENT_PERMISSIONS ||
|
||||
error.status === 403
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry an API call with exponential backoff
|
||||
*
|
||||
* @param fn - Async function to retry
|
||||
* @param options - Retry configuration
|
||||
* @returns Result of successful call
|
||||
*
|
||||
* @example
|
||||
* const data = await retryApiCall(
|
||||
* () => fetch('/api/users').then(r => r.json()),
|
||||
* { maxRetries: 3, initialDelay: 1000 }
|
||||
* );
|
||||
*/
|
||||
export async function retryApiCall<T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: {
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
shouldRetry?: (error: unknown) => boolean;
|
||||
},
|
||||
): Promise<T> {
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const initialDelay = options?.initialDelay ?? 1000;
|
||||
const maxDelay = options?.maxDelay ?? 10000;
|
||||
const shouldRetry =
|
||||
options?.shouldRetry ??
|
||||
((error: unknown) => {
|
||||
// Retry on network errors and 5xx errors
|
||||
if (isNetworkError(error)) return true;
|
||||
if (error instanceof ApiError && error.status && error.status >= 500) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
let lastError: unknown;
|
||||
let delay = initialDelay;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Don't retry if this was the last attempt or if error is not retryable
|
||||
if (attempt === maxRetries || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
// Exponential backoff with max delay cap
|
||||
delay = Math.min(delay * 2, maxDelay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a type-safe API response wrapper
|
||||
*
|
||||
* @param data - Response data
|
||||
* @returns Standardized success response
|
||||
*/
|
||||
export function createSuccessResponse<T>(data: T): ApiSuccessResponse<T> {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a type-safe error response
|
||||
*
|
||||
* @param message - Error message
|
||||
* @param code - Error code
|
||||
* @param details - Additional error details
|
||||
* @returns Standardized error response
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
message: string,
|
||||
code?: string,
|
||||
details?: Record<string, string[]>,
|
||||
): ApiErrorResponse {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
message,
|
||||
code,
|
||||
details,
|
||||
},
|
||||
meta: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Standardized API Response Types for FitAI Mobile App
|
||||
*
|
||||
* These types ensure consistent response structures when consuming the API.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Standard success response wrapper
|
||||
*/
|
||||
export interface ApiSuccessResponse<T = unknown> {
|
||||
success: true;
|
||||
data: T;
|
||||
meta?: ResponseMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard error response wrapper
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: Record<string, string[]>;
|
||||
};
|
||||
meta?: ResponseMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all API responses
|
||||
*/
|
||||
export type ApiResponse<T = unknown> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
|
||||
/**
|
||||
* Pagination metadata for list endpoints
|
||||
*/
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response metadata included in all responses
|
||||
*/
|
||||
export interface ResponseMeta {
|
||||
timestamp: string;
|
||||
requestId?: string;
|
||||
pagination?: PaginationMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated list response
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
success: true;
|
||||
data: T[];
|
||||
meta: ResponseMeta & {
|
||||
pagination: PaginationMeta;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response is successful
|
||||
*/
|
||||
export function isSuccessResponse<T>(
|
||||
response: ApiResponse<T>,
|
||||
): response is ApiSuccessResponse<T> {
|
||||
return response.success === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if response is an error
|
||||
*/
|
||||
export function isErrorResponse(
|
||||
response: ApiResponse<unknown>,
|
||||
): response is ApiErrorResponse {
|
||||
return response.success === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common API entity types
|
||||
*/
|
||||
|
||||
export interface UserResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
phone?: string | null;
|
||||
gymId?: string | null;
|
||||
gymName?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
client?: ClientResponse | null;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: string | null;
|
||||
lastCheckInTime?: string | null;
|
||||
checkInsThisWeek?: number;
|
||||
checkInsThisMonth?: number;
|
||||
}
|
||||
|
||||
export interface ClientResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
membershipType: string;
|
||||
membershipStatus: string;
|
||||
joinDate: string;
|
||||
}
|
||||
|
||||
export interface FitnessGoalResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string | null;
|
||||
goalType: string;
|
||||
title: string;
|
||||
description: string;
|
||||
targetValue: number;
|
||||
currentValue: number;
|
||||
unit: string;
|
||||
startDate: string;
|
||||
targetDate?: string | null;
|
||||
completedDate?: string | null;
|
||||
status: string;
|
||||
progress: number;
|
||||
priority: string;
|
||||
notes?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FitnessProfileResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
age: number;
|
||||
gender: string;
|
||||
height: number;
|
||||
weight: number;
|
||||
activityLevel: string;
|
||||
goals: string;
|
||||
medicalConditions?: string | null;
|
||||
injuries?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RecommendationResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string | null;
|
||||
type: string;
|
||||
recommendationText: string;
|
||||
activityPlan?: string | null;
|
||||
dietPlan?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AttendanceResponse {
|
||||
id: string;
|
||||
userId: string;
|
||||
gymId?: string | null;
|
||||
checkInTime: string;
|
||||
checkOutTime?: string | null;
|
||||
duration?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GymResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
zipCode?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common error codes
|
||||
*/
|
||||
export enum ApiErrorCode {
|
||||
// Authentication & Authorization
|
||||
UNAUTHORIZED = "UNAUTHORIZED",
|
||||
FORBIDDEN = "FORBIDDEN",
|
||||
INVALID_TOKEN = "INVALID_TOKEN",
|
||||
|
||||
// Validation
|
||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
||||
INVALID_INPUT = "INVALID_INPUT",
|
||||
MISSING_REQUIRED_FIELD = "MISSING_REQUIRED_FIELD",
|
||||
|
||||
// Resource errors
|
||||
NOT_FOUND = "NOT_FOUND",
|
||||
ALREADY_EXISTS = "ALREADY_EXISTS",
|
||||
CONFLICT = "CONFLICT",
|
||||
|
||||
// Server errors
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR",
|
||||
DATABASE_ERROR = "DATABASE_ERROR",
|
||||
EXTERNAL_SERVICE_ERROR = "EXTERNAL_SERVICE_ERROR",
|
||||
|
||||
// Business logic errors
|
||||
INSUFFICIENT_PERMISSIONS = "INSUFFICIENT_PERMISSIONS",
|
||||
INVALID_OPERATION = "INVALID_OPERATION",
|
||||
RESOURCE_LOCKED = "RESOURCE_LOCKED",
|
||||
|
||||
// Network errors
|
||||
NETWORK_ERROR = "NETWORK_ERROR",
|
||||
TIMEOUT = "TIMEOUT",
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
/**
|
||||
* Format Zod validation errors into user-friendly messages
|
||||
*
|
||||
* @param error - The ZodError to format
|
||||
* @returns Object with field paths as keys and error messages as values
|
||||
*
|
||||
* @example
|
||||
* const errors = formatZodErrors(zodError)
|
||||
* // { "email": ["Invalid email address"], "password": ["Password too short"] }
|
||||
*/
|
||||
export function formatZodErrors(error: ZodError): Record<string, string[]> {
|
||||
const formatted: Record<string, string[]> = {};
|
||||
|
||||
for (const issue of error.issues) {
|
||||
const path = issue.path.join(".") || "root";
|
||||
if (!formatted[path]) {
|
||||
formatted[path] = [];
|
||||
}
|
||||
formatted[path].push(issue.message);
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate data against a Zod schema
|
||||
* Throws an error if validation fails
|
||||
*
|
||||
* @param schema - The Zod schema to validate against
|
||||
* @param data - The data to validate
|
||||
* @returns The validated and typed data
|
||||
*
|
||||
* @example
|
||||
* try {
|
||||
* const validUser = validateData(userSchema, userData)
|
||||
* // Use validUser (type-safe)
|
||||
* } catch (error) {
|
||||
* console.error('Validation failed:', error.message)
|
||||
* }
|
||||
*/
|
||||
export function validateData<T>(schema: z.ZodSchema<T>, data: unknown): T {
|
||||
return schema.parse(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe validation that returns a result object instead of throwing
|
||||
*
|
||||
* @param schema - The Zod schema to validate against
|
||||
* @param data - The data to validate
|
||||
* @returns Success object with validated data or failure object with errors
|
||||
*
|
||||
* @example
|
||||
* const result = safeValidate(userSchema, userData)
|
||||
* if (!result.success) {
|
||||
* Alert.alert('Validation Error', getFirstError(result.errors))
|
||||
* return
|
||||
* }
|
||||
* const { email, password } = result.data
|
||||
*/
|
||||
export function safeValidate<T>(
|
||||
schema: z.ZodSchema<T>,
|
||||
data: unknown,
|
||||
):
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string[]> } {
|
||||
try {
|
||||
const validData = schema.parse(data);
|
||||
return { success: true, data: validData };
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return { success: false, errors: formatZodErrors(error) };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first error message for display in UI
|
||||
*
|
||||
* @param errors - The formatted validation errors
|
||||
* @returns The first error message found
|
||||
*
|
||||
* @example
|
||||
* const firstError = getFirstError(errors)
|
||||
* Alert.alert('Error', firstError)
|
||||
*/
|
||||
export function getFirstError(errors: Record<string, string[]>): string {
|
||||
const firstField = Object.keys(errors)[0];
|
||||
return errors[firstField]?.[0] || "Validation error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all error messages as a flat array
|
||||
*
|
||||
* @param errors - The formatted validation errors
|
||||
* @returns Array of all error messages
|
||||
*
|
||||
* @example
|
||||
* const allErrors = getAllErrors(errors)
|
||||
* allErrors.forEach(err => console.error(err))
|
||||
*/
|
||||
export function getAllErrors(errors: Record<string, string[]>): string[] {
|
||||
return Object.values(errors).flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get errors for a specific field
|
||||
*
|
||||
* @param errors - The formatted validation errors
|
||||
* @param fieldPath - The field path (e.g., "email", "address.city")
|
||||
* @returns Array of error messages for the field, or empty array if none
|
||||
*
|
||||
* @example
|
||||
* const emailErrors = getFieldErrors(errors, 'email')
|
||||
* if (emailErrors.length > 0) {
|
||||
* setEmailErrorText(emailErrors[0])
|
||||
* }
|
||||
*/
|
||||
export function getFieldErrors(
|
||||
errors: Record<string, string[]>,
|
||||
fieldPath: string,
|
||||
): string[] {
|
||||
return errors[fieldPath] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific field has validation errors
|
||||
*
|
||||
* @param errors - The formatted validation errors
|
||||
* @param fieldPath - The field path to check
|
||||
* @returns True if the field has errors
|
||||
*
|
||||
* @example
|
||||
* const hasEmailError = hasFieldError(errors, 'email')
|
||||
* <TextInput error={hasEmailError} />
|
||||
*/
|
||||
export function hasFieldError(
|
||||
errors: Record<string, string[]>,
|
||||
fieldPath: string,
|
||||
): boolean {
|
||||
return fieldPath in errors && errors[fieldPath].length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation errors for display in a form
|
||||
* Returns an object suitable for form error state
|
||||
*
|
||||
* @param errors - The formatted validation errors
|
||||
* @returns Object with field paths as keys and first error message as value
|
||||
*
|
||||
* @example
|
||||
* const formErrors = formatFormErrors(errors)
|
||||
* setErrors(formErrors)
|
||||
* // { email: "Invalid email address", password: "Password too short" }
|
||||
*/
|
||||
export function formatFormErrors(
|
||||
errors: Record<string, string[]>,
|
||||
): Record<string, string> {
|
||||
const formatted: Record<string, string> = {};
|
||||
|
||||
for (const [field, messages] of Object.entries(errors)) {
|
||||
if (messages.length > 0) {
|
||||
formatted[field] = messages[0];
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate form data before submission
|
||||
* Useful for client-side form validation
|
||||
*
|
||||
* @param schema - The Zod schema to validate against
|
||||
* @param formData - The form data object
|
||||
* @returns Validation result with formatted errors for forms
|
||||
*
|
||||
* @example
|
||||
* const handleSubmit = () => {
|
||||
* const result = validateForm(goalSchema, {
|
||||
* title: goalTitle,
|
||||
* description: goalDescription,
|
||||
* targetValue: parseFloat(targetValue),
|
||||
* })
|
||||
*
|
||||
* if (!result.success) {
|
||||
* setFormErrors(result.formErrors)
|
||||
* Alert.alert('Validation Error', result.message)
|
||||
* return
|
||||
* }
|
||||
*
|
||||
* // Submit result.data
|
||||
* }
|
||||
*/
|
||||
export function validateForm<T>(
|
||||
schema: z.ZodSchema<T>,
|
||||
formData: unknown,
|
||||
):
|
||||
| {
|
||||
success: true;
|
||||
data: T;
|
||||
formErrors?: never;
|
||||
message?: never;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
data?: never;
|
||||
formErrors: Record<string, string>;
|
||||
message: string;
|
||||
} {
|
||||
const result = safeValidate(schema, formData);
|
||||
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
formErrors: formatFormErrors(result.errors),
|
||||
message: getFirstError(result.errors),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ============================================================================
|
||||
// Common validation rules
|
||||
// ============================================================================
|
||||
|
||||
export const emailSchema = z.string().email("Invalid email address");
|
||||
|
||||
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 phoneSchema = z
|
||||
.string()
|
||||
.regex(/^\+?[1-9]\d{1,14}$/, "Invalid phone number format")
|
||||
.optional();
|
||||
|
||||
export const dateTimeSchema = z.string().datetime("Invalid datetime format");
|
||||
|
||||
// ============================================================================
|
||||
// User schemas
|
||||
// ============================================================================
|
||||
|
||||
export const userRoleSchema = z.enum([
|
||||
"client",
|
||||
"trainer",
|
||||
"admin",
|
||||
"superAdmin",
|
||||
]);
|
||||
|
||||
export const userSchema = 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: userRoleSchema.default("client"),
|
||||
});
|
||||
|
||||
export const userUpdateSchema = userSchema.partial();
|
||||
|
||||
export const userLoginSchema = z.object({
|
||||
email: emailSchema,
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Fitness Goal schemas
|
||||
// ============================================================================
|
||||
|
||||
export const goalTypeSchema = z.enum([
|
||||
"weight_target",
|
||||
"strength_milestone",
|
||||
"endurance_target",
|
||||
"flexibility_goal",
|
||||
"habit_building",
|
||||
"custom",
|
||||
]);
|
||||
|
||||
export const prioritySchema = z.enum(["low", "medium", "high"]);
|
||||
|
||||
export const goalStatusSchema = z.enum(["active", "completed", "abandoned"]);
|
||||
|
||||
export const fitnessGoalSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
goalType: goalTypeSchema,
|
||||
title: z.string().min(1, "Title is required").max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
targetValue: z.number().positive("Target value must be positive").optional(),
|
||||
currentValue: z
|
||||
.number()
|
||||
.nonnegative("Current value cannot be negative")
|
||||
.optional(),
|
||||
unit: z.string().max(20).optional(),
|
||||
targetDate: dateTimeSchema.optional(),
|
||||
priority: prioritySchema.default("medium"),
|
||||
status: goalStatusSchema.default("active"),
|
||||
notes: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const fitnessGoalUpdateSchema = fitnessGoalSchema.partial().omit({
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export const fitnessGoalCreateSchema = fitnessGoalSchema.omit({
|
||||
userId: true,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Fitness Profile schemas
|
||||
// ============================================================================
|
||||
|
||||
export const genderSchema = z.enum([
|
||||
"male",
|
||||
"female",
|
||||
"other",
|
||||
"prefer_not_to_say",
|
||||
]);
|
||||
|
||||
export const activityLevelSchema = z.enum([
|
||||
"sedentary",
|
||||
"lightly_active",
|
||||
"moderately_active",
|
||||
"very_active",
|
||||
"extremely_active",
|
||||
]);
|
||||
|
||||
export const fitnessProfileSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
height: z
|
||||
.number()
|
||||
.positive("Height must be positive")
|
||||
.max(300, "Invalid height value"),
|
||||
weight: z
|
||||
.number()
|
||||
.positive("Weight must be positive")
|
||||
.max(500, "Invalid weight value"),
|
||||
age: z
|
||||
.number()
|
||||
.int("Age must be an integer")
|
||||
.positive("Age must be positive")
|
||||
.min(13, "Must be at least 13 years old")
|
||||
.max(120, "Invalid age value"),
|
||||
gender: genderSchema.optional(),
|
||||
activityLevel: activityLevelSchema.optional(),
|
||||
fitnessGoals: z
|
||||
.array(z.string())
|
||||
.max(10, "Maximum 10 goals allowed")
|
||||
.optional(),
|
||||
medicalConditions: z.string().max(1000).optional(),
|
||||
allergies: z.string().max(500).optional(),
|
||||
injuries: z.string().max(500).optional(),
|
||||
preferences: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const fitnessProfileUpdateSchema = fitnessProfileSchema.partial().omit({
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export const fitnessProfileCreateSchema = fitnessProfileSchema.omit({
|
||||
userId: true,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Recommendation schemas
|
||||
// ============================================================================
|
||||
|
||||
export const recommendationTypeSchema = z.enum([
|
||||
"workout",
|
||||
"nutrition",
|
||||
"lifestyle",
|
||||
"recovery",
|
||||
]);
|
||||
|
||||
export const recommendationStatusSchema = z.enum([
|
||||
"pending",
|
||||
"accepted",
|
||||
"rejected",
|
||||
"completed",
|
||||
]);
|
||||
|
||||
export const recommendationSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
type: recommendationTypeSchema,
|
||||
title: z.string().min(1, "Title is required").max(200),
|
||||
description: z.string().min(1, "Description is required").max(2000),
|
||||
reasoning: z.string().max(1000).optional(),
|
||||
priority: prioritySchema.default("medium"),
|
||||
status: recommendationStatusSchema.default("pending"),
|
||||
metadata: z.record(z.unknown()).optional(),
|
||||
aiGenerated: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const recommendationUpdateSchema = recommendationSchema.partial().omit({
|
||||
userId: true,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Attendance schemas
|
||||
// ============================================================================
|
||||
|
||||
export const attendanceSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
gymId: z.string().min(1, "Gym ID is required"),
|
||||
checkInTime: dateTimeSchema,
|
||||
checkOutTime: dateTimeSchema.optional(),
|
||||
});
|
||||
|
||||
export const checkInSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
gymId: z.string().min(1, "Gym ID is required"),
|
||||
});
|
||||
|
||||
export const checkOutSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
attendanceId: z.string().min(1, "Attendance ID is required"),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type exports (inferred from schemas)
|
||||
// ============================================================================
|
||||
|
||||
export type User = z.infer<typeof userSchema>;
|
||||
export type UserUpdate = z.infer<typeof userUpdateSchema>;
|
||||
export type UserLogin = z.infer<typeof userLoginSchema>;
|
||||
export type UserRole = z.infer<typeof userRoleSchema>;
|
||||
|
||||
export type FitnessGoal = z.infer<typeof fitnessGoalSchema>;
|
||||
export type FitnessGoalUpdate = z.infer<typeof fitnessGoalUpdateSchema>;
|
||||
export type FitnessGoalCreate = z.infer<typeof fitnessGoalCreateSchema>;
|
||||
export type GoalType = z.infer<typeof goalTypeSchema>;
|
||||
export type GoalStatus = z.infer<typeof goalStatusSchema>;
|
||||
|
||||
export type FitnessProfile = z.infer<typeof fitnessProfileSchema>;
|
||||
export type FitnessProfileUpdate = z.infer<typeof fitnessProfileUpdateSchema>;
|
||||
export type FitnessProfileCreate = z.infer<typeof fitnessProfileCreateSchema>;
|
||||
export type Gender = z.infer<typeof genderSchema>;
|
||||
export type ActivityLevel = z.infer<typeof activityLevelSchema>;
|
||||
|
||||
export type Recommendation = z.infer<typeof recommendationSchema>;
|
||||
export type RecommendationUpdate = z.infer<typeof recommendationUpdateSchema>;
|
||||
export type RecommendationType = z.infer<typeof recommendationTypeSchema>;
|
||||
export type RecommendationStatus = z.infer<typeof recommendationStatusSchema>;
|
||||
|
||||
export type Attendance = z.infer<typeof attendanceSchema>;
|
||||
export type CheckIn = z.infer<typeof checkInSchema>;
|
||||
export type CheckOut = z.infer<typeof checkOutSchema>;
|
||||
|
||||
export type Priority = z.infer<typeof prioritySchema>;
|
||||
Reference in new issue
Block a user