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,202 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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 request body against a Zod schema
|
||||
*
|
||||
* @param request - The Next.js request object
|
||||
* @param schema - The Zod schema to validate against
|
||||
* @returns Success object with validated data or failure object with errors
|
||||
*
|
||||
* @example
|
||||
* const result = await validateRequestBody(request, userSchema)
|
||||
* if (!result.success) {
|
||||
* return validationErrorResponse(result.errors)
|
||||
* }
|
||||
* const { email, password } = result.data
|
||||
*/
|
||||
export async function validateRequestBody<T>(
|
||||
request: NextRequest,
|
||||
schema: z.ZodSchema<T>,
|
||||
): Promise<
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string[]> }
|
||||
> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const data = schema.parse(body);
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return { success: false, errors: formatZodErrors(error) };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL query parameters against a Zod schema
|
||||
*
|
||||
* @param request - The Next.js request object
|
||||
* @param schema - The Zod schema to validate against
|
||||
* @returns Success object with validated data or failure object with errors
|
||||
*
|
||||
* @example
|
||||
* const result = validateQueryParams(request, paginationSchema)
|
||||
* if (!result.success) {
|
||||
* return validationErrorResponse(result.errors)
|
||||
* }
|
||||
* const { page, limit } = result.data
|
||||
*/
|
||||
export function validateQueryParams<T>(
|
||||
request: NextRequest,
|
||||
schema: z.ZodSchema<T>,
|
||||
):
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string[]> } {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const params = Object.fromEntries(searchParams.entries());
|
||||
const data = schema.parse(params);
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return { success: false, errors: formatZodErrors(error) };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate data against a Zod schema (generic validation)
|
||||
*
|
||||
* @param data - The data to validate
|
||||
* @param schema - The Zod schema to validate against
|
||||
* @returns Success object with validated data or failure object with errors
|
||||
*
|
||||
* @example
|
||||
* const result = validateData(userData, userSchema)
|
||||
* if (!result.success) {
|
||||
* console.error('Validation failed:', result.errors)
|
||||
* }
|
||||
*/
|
||||
export function validateData<T>(
|
||||
data: unknown,
|
||||
schema: z.ZodSchema<T>,
|
||||
):
|
||||
| { 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized validation error response
|
||||
*
|
||||
* @param errors - The formatted validation errors
|
||||
* @param message - Optional custom error message
|
||||
* @returns Next.js JSON response with 400 status
|
||||
*
|
||||
* @example
|
||||
* return validationErrorResponse(errors)
|
||||
* // Response: { error: "Validation failed", details: { ... } }
|
||||
*/
|
||||
export function validationErrorResponse(
|
||||
errors: Record<string, string[]>,
|
||||
message = "Validation failed",
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: message,
|
||||
details: errors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first error message from validation errors (for simple displays)
|
||||
*
|
||||
* @param errors - The formatted validation errors
|
||||
* @returns The first error message found
|
||||
*
|
||||
* @example
|
||||
* const firstError = getFirstError(errors)
|
||||
* // "Invalid email address"
|
||||
*/
|
||||
export function getFirstError(errors: Record<string, string[]>): string {
|
||||
const firstField = Object.keys(errors)[0];
|
||||
return errors[firstField]?.[0] || "Validation error";
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine multiple validation results
|
||||
* Returns success only if all validations pass
|
||||
*
|
||||
* @param results - Array of validation results
|
||||
* @returns Combined validation result
|
||||
*
|
||||
* @example
|
||||
* const bodyValidation = await validateRequestBody(request, schema1)
|
||||
* const queryValidation = validateQueryParams(request, schema2)
|
||||
* const combined = combineValidations([bodyValidation, queryValidation])
|
||||
* if (!combined.success) {
|
||||
* return validationErrorResponse(combined.errors)
|
||||
* }
|
||||
*/
|
||||
export function combineValidations<T extends Record<string, unknown>>(
|
||||
results: Array<
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string[]> }
|
||||
>,
|
||||
):
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string[]> } {
|
||||
const allErrors: Record<string, string[]> = {};
|
||||
let combinedData: T = {} as T;
|
||||
|
||||
for (const result of results) {
|
||||
if (!result.success) {
|
||||
Object.assign(allErrors, result.errors);
|
||||
} else {
|
||||
combinedData = { ...combinedData, ...result.data };
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(allErrors).length > 0) {
|
||||
return { success: false, errors: allErrors };
|
||||
}
|
||||
|
||||
return { success: true, data: combinedData };
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
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"),
|
||||
});
|
||||
|
||||
export const userInviteSchema = z.object({
|
||||
email: emailSchema,
|
||||
firstName: z.string().min(1, "First name is required").max(50),
|
||||
lastName: z.string().min(1, "Last name is required").max(50),
|
||||
role: userRoleSchema,
|
||||
phone: phoneSchema,
|
||||
});
|
||||
|
||||
export const userUpdateWithIdSchema = z.object({
|
||||
id: z.string().min(1, "User ID is required"),
|
||||
email: emailSchema.optional(),
|
||||
firstName: z.string().min(1, "First name is required").max(50).optional(),
|
||||
lastName: z.string().min(1, "Last name is required").max(50).optional(),
|
||||
role: userRoleSchema.optional(),
|
||||
phone: phoneSchema,
|
||||
gymId: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
.omit({ userId: true })
|
||||
.partial();
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
.omit({ userId: true })
|
||||
.partial();
|
||||
|
||||
// ============================================================================
|
||||
// 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.string(), z.unknown()).optional(),
|
||||
aiGenerated: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const recommendationUpdateSchema = recommendationSchema.partial().omit({
|
||||
userId: true,
|
||||
});
|
||||
|
||||
export const generateRecommendationSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
type: recommendationTypeSchema.optional(),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.min(1)
|
||||
.max(10, "Maximum 10 recommendations per request")
|
||||
.default(3),
|
||||
includeContext: z.boolean().default(true),
|
||||
});
|
||||
|
||||
// Legacy recommendation schema for backward compatibility
|
||||
export const legacyRecommendationSchema = z
|
||||
.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
fitnessProfileId: z.string().optional(),
|
||||
recommendationText: z.string().min(1).max(5000).optional(),
|
||||
activityPlan: z.string().max(5000).optional(),
|
||||
dietPlan: z.string().max(5000).optional(),
|
||||
type: z.string().optional(),
|
||||
content: z.string().max(5000).optional(),
|
||||
status: recommendationStatusSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.recommendationText && data.activityPlan && data.dietPlan) ||
|
||||
(data.type && data.content),
|
||||
{
|
||||
message:
|
||||
"Either provide (recommendationText, activityPlan, dietPlan) or (type, content)",
|
||||
},
|
||||
);
|
||||
|
||||
export const recommendationUpdateRequestSchema = z.object({
|
||||
id: z.string().min(1, "Recommendation ID is required"),
|
||||
status: recommendationStatusSchema.optional(),
|
||||
recommendationText: z.string().max(5000).optional(),
|
||||
activityPlan: z.string().max(5000).optional(),
|
||||
dietPlan: z.string().max(5000).optional(),
|
||||
content: z.string().max(5000).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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"),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Gym schemas
|
||||
// ============================================================================
|
||||
|
||||
export const gymSchema = z.object({
|
||||
name: z.string().min(1, "Gym name is required").max(100),
|
||||
address: z.string().min(1, "Address is required").max(200),
|
||||
city: z.string().min(1, "City is required").max(100),
|
||||
state: z.string().min(2, "State is required").max(50),
|
||||
zipCode: z
|
||||
.string()
|
||||
.regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP code format")
|
||||
.optional(),
|
||||
phone: phoneSchema,
|
||||
email: emailSchema.optional(),
|
||||
capacity: z.number().int().positive("Capacity must be positive").optional(),
|
||||
amenities: z.array(z.string()).max(50).optional(),
|
||||
operatingHours: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const gymUpdateSchema = gymSchema.partial();
|
||||
|
||||
export const assignUserToGymSchema = z.object({
|
||||
userId: z.string().min(1, "User ID is required"),
|
||||
gymId: z.string().min(1, "Gym ID is required"),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Invitation schemas
|
||||
// ============================================================================
|
||||
|
||||
export const invitationSchema = z.object({
|
||||
email: emailSchema,
|
||||
role: z.enum(["client", "trainer"]).default("client"),
|
||||
gymId: z.string().min(1, "Gym ID is required").optional(),
|
||||
expiresAt: dateTimeSchema.optional(),
|
||||
message: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const invitationUpdateSchema = z.object({
|
||||
status: z.enum(["pending", "accepted", "rejected", "expired"]),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Query parameter schemas
|
||||
// ============================================================================
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
sortBy: z.string().optional(),
|
||||
sortOrder: z.enum(["asc", "desc"]).default("asc"),
|
||||
});
|
||||
|
||||
export const statusFilterSchema = z
|
||||
.enum(["active", "completed", "all"])
|
||||
.default("all");
|
||||
|
||||
export const roleFilterSchema = z
|
||||
.enum(["client", "trainer", "admin", "superAdmin", "all"])
|
||||
.default("all");
|
||||
|
||||
export const dateRangeSchema = z.object({
|
||||
startDate: dateTimeSchema.optional(),
|
||||
endDate: dateTimeSchema.optional(),
|
||||
});
|
||||
|
||||
export const searchSchema = z.object({
|
||||
query: z.string().min(1).max(100),
|
||||
fields: z.array(z.string()).max(10).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// 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 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 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 GenerateRecommendation = z.infer<
|
||||
typeof generateRecommendationSchema
|
||||
>;
|
||||
|
||||
export type Attendance = z.infer<typeof attendanceSchema>;
|
||||
export type CheckIn = z.infer<typeof checkInSchema>;
|
||||
export type CheckOut = z.infer<typeof checkOutSchema>;
|
||||
|
||||
export type Gym = z.infer<typeof gymSchema>;
|
||||
export type GymUpdate = z.infer<typeof gymUpdateSchema>;
|
||||
export type AssignUserToGym = z.infer<typeof assignUserToGymSchema>;
|
||||
|
||||
export type Invitation = z.infer<typeof invitationSchema>;
|
||||
export type InvitationUpdate = z.infer<typeof invitationUpdateSchema>;
|
||||
|
||||
export type Pagination = z.infer<typeof paginationSchema>;
|
||||
export type StatusFilter = z.infer<typeof statusFilterSchema>;
|
||||
export type RoleFilter = z.infer<typeof roleFilterSchema>;
|
||||
export type DateRange = z.infer<typeof dateRangeSchema>;
|
||||
export type Search = z.infer<typeof searchSchema>;
|
||||
export type Priority = z.infer<typeof prioritySchema>;
|
||||
Reference in new issue
Block a user