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
@@ -1,10 +1,15 @@
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import log from "@/lib/logger";
|
||||
import { checkInSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
@@ -2,48 +2,50 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
import { userLoginSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
unauthorizedResponse,
|
||||
internalErrorResponse,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const { email, password } = await request.json();
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, userLoginSchema);
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email and password are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!validation.success) {
|
||||
log.warn("Login validation failed", { errors: validation.errors });
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const { email, password } = validation.data;
|
||||
|
||||
const db = await getDatabase();
|
||||
const user = await db.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
return unauthorizedResponse("Invalid credentials");
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
return unauthorizedResponse("Invalid credentials");
|
||||
}
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
return NextResponse.json({
|
||||
return successResponse({
|
||||
message: "Login successful",
|
||||
user: userWithoutPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Login failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,25 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
import { userSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const { email, password, firstName, lastName, phone } =
|
||||
await request.json();
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, userSchema);
|
||||
|
||||
if (!email || !password || !firstName || !lastName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!validation.success) {
|
||||
log.warn("Registration validation failed", { errors: validation.errors });
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const { email, password, firstName, lastName, phone } = validation.data;
|
||||
|
||||
const db = await getDatabase();
|
||||
const existingUser = await db.getUserByEmail(email);
|
||||
|
||||
if (existingUser) {
|
||||
|
||||
@@ -2,19 +2,23 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
import { fitnessGoalUpdateSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
unauthorizedResponse,
|
||||
notFoundResponse,
|
||||
forbiddenResponse,
|
||||
internalErrorResponse,
|
||||
addCorsHeaders,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
// OPTIONS - Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return NextResponse.json({}, { headers: corsHeaders() });
|
||||
return NextResponse.json({}, { headers: addCorsHeaders() });
|
||||
}
|
||||
|
||||
// GET - Get specific goal
|
||||
@@ -25,10 +29,7 @@ export async function GET(
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -37,27 +38,18 @@ export async function GET(
|
||||
const goal = await db.getFitnessGoalById(id);
|
||||
|
||||
if (!goal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
return notFoundResponse("Goal not found", addCorsHeaders());
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (goal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
return forbiddenResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
return NextResponse.json(goal, { headers: corsHeaders() });
|
||||
return successResponse(goal, { headers: addCorsHeaders() });
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +61,7 @@ export async function PUT(
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -81,34 +70,36 @@ export async function PUT(
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
return notFoundResponse("Goal not found", addCorsHeaders());
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
return forbiddenResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const updates = await req.json();
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(req, fitnessGoalUpdateSchema);
|
||||
|
||||
// Don't allow changing userId or id
|
||||
delete updates.userId;
|
||||
delete updates.id;
|
||||
delete updates.createdAt;
|
||||
if (!validation.success) {
|
||||
log.warn("Fitness goal update validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
// Transform targetDate from string to Date if present
|
||||
const updates = {
|
||||
...validation.data,
|
||||
targetDate: validation.data.targetDate
|
||||
? new Date(validation.data.targetDate)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const updatedGoal = await db.updateFitnessGoal(id, updates);
|
||||
|
||||
return NextResponse.json(updatedGoal, { headers: corsHeaders() });
|
||||
return successResponse(updatedGoal, { headers: addCorsHeaders() });
|
||||
} catch (error) {
|
||||
log.error("Failed to update fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,10 +111,7 @@ export async function DELETE(
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -132,33 +120,21 @@ export async function DELETE(
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
return notFoundResponse("Goal not found", addCorsHeaders());
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
return forbiddenResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const deleted = await db.deleteFitnessGoal(id);
|
||||
|
||||
if (deleted) {
|
||||
return NextResponse.json({ success: true }, { headers: corsHeaders() });
|
||||
return successResponse({ deleted: true }, { headers: addCorsHeaders() });
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete goal" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse("Failed to delete goal", addCorsHeaders());
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,23 @@ import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { randomBytes } from "crypto";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
import {
|
||||
fitnessGoalSchema,
|
||||
paginationSchema,
|
||||
statusFilterSchema,
|
||||
} from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validateQueryParams,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
createdResponse,
|
||||
unauthorizedResponse,
|
||||
internalErrorResponse,
|
||||
addCorsHeaders,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
// Helper to get user ID from auth (works with both web sessions and mobile tokens)
|
||||
async function getAuthenticatedUserId(
|
||||
@@ -25,7 +33,7 @@ async function getAuthenticatedUserId(
|
||||
|
||||
// OPTIONS - Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return NextResponse.json({}, { headers: corsHeaders() });
|
||||
return NextResponse.json({}, { headers: addCorsHeaders() });
|
||||
}
|
||||
|
||||
// GET - List user's fitness goals
|
||||
@@ -37,10 +45,7 @@ export async function GET(req: NextRequest) {
|
||||
log.error("Fitness goals GET - authentication failed", undefined, {
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
log.debug("Fitness goals GET - authenticated user", { userId });
|
||||
@@ -61,13 +66,10 @@ export async function GET(req: NextRequest) {
|
||||
count: goals.length,
|
||||
userId: targetUserId,
|
||||
});
|
||||
return NextResponse.json(goals, { headers: corsHeaders() });
|
||||
return successResponse(goals, { headers: addCorsHeaders() });
|
||||
} catch (error) {
|
||||
log.error("Failed to get fitness goals", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +80,19 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (!userId) {
|
||||
log.error("Fitness goals POST - authentication failed");
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(req, fitnessGoalSchema);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Fitness goal creation validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
goalType,
|
||||
title,
|
||||
@@ -94,17 +102,9 @@ export async function POST(req: NextRequest) {
|
||||
unit,
|
||||
targetDate,
|
||||
priority,
|
||||
status,
|
||||
notes,
|
||||
fitnessProfileId,
|
||||
} = body;
|
||||
|
||||
// Validation
|
||||
if (!goalType || !title) {
|
||||
return NextResponse.json(
|
||||
{ error: "goalType and title are required" },
|
||||
{ status: 400, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
} = validation.data;
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
@@ -112,28 +112,25 @@ export async function POST(req: NextRequest) {
|
||||
const goal = await db.createFitnessGoal({
|
||||
id: randomBytes(16).toString("hex"),
|
||||
userId,
|
||||
fitnessProfileId: fitnessProfileId || undefined,
|
||||
fitnessProfileId: undefined,
|
||||
goalType,
|
||||
title,
|
||||
description: description || undefined,
|
||||
targetValue: targetValue || undefined,
|
||||
description,
|
||||
targetValue,
|
||||
currentValue: currentValue || 0,
|
||||
unit: unit || undefined,
|
||||
unit,
|
||||
startDate: new Date(),
|
||||
targetDate: targetDate ? new Date(targetDate) : undefined,
|
||||
status: "active",
|
||||
status: status || "active",
|
||||
progress: 0,
|
||||
priority: priority || "medium",
|
||||
notes: notes || undefined,
|
||||
notes,
|
||||
});
|
||||
|
||||
log.info("Fitness goal created", { goalId: goal.id, userId });
|
||||
return NextResponse.json(goal, { status: 201, headers: corsHeaders() });
|
||||
return createdResponse(goal, { headers: addCorsHeaders() });
|
||||
} catch (error) {
|
||||
log.error("Failed to create fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { auth } from "@clerk/nextjs/server";
|
||||
import Database from "better-sqlite3";
|
||||
import { randomBytes } from "crypto";
|
||||
import log from "@/lib/logger";
|
||||
import { fitnessProfileSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
|
||||
const db = new Database("./data/fitai.db");
|
||||
|
||||
@@ -48,20 +53,28 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, fitnessProfileSchema);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Fitness profile validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const {
|
||||
height,
|
||||
weight,
|
||||
age,
|
||||
gender,
|
||||
fitnessGoals, // Changed from fitnessGoal
|
||||
fitnessGoals,
|
||||
activityLevel,
|
||||
medicalConditions,
|
||||
allergies,
|
||||
injuries,
|
||||
exerciseHabits,
|
||||
dietHabits,
|
||||
} = body;
|
||||
preferences,
|
||||
} = validation.data;
|
||||
|
||||
// Check if profile exists
|
||||
const existingProfile = db
|
||||
@@ -84,22 +97,20 @@ export async function POST(request: NextRequest) {
|
||||
medicalConditions = ?,
|
||||
allergies = ?,
|
||||
injuries = ?,
|
||||
exerciseHabits = ?,
|
||||
dietHabits = ?,
|
||||
preferences = ?,
|
||||
updatedAt = ?
|
||||
WHERE userId = ?`,
|
||||
).run(
|
||||
height || null,
|
||||
weight || null,
|
||||
age || null,
|
||||
height,
|
||||
weight,
|
||||
age,
|
||||
gender || null,
|
||||
fitnessGoalsJson,
|
||||
activityLevel || null,
|
||||
medicalConditions || null,
|
||||
allergies || null,
|
||||
injuries || null,
|
||||
exerciseHabits || null,
|
||||
dietHabits || null,
|
||||
preferences || null,
|
||||
now,
|
||||
userId,
|
||||
);
|
||||
@@ -113,21 +124,20 @@ export async function POST(request: NextRequest) {
|
||||
db.prepare(
|
||||
`INSERT INTO fitness_profiles
|
||||
(userId, height, weight, age, gender, fitnessGoals, activityLevel,
|
||||
medicalConditions, allergies, injuries, exerciseHabits, dietHabits, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
medicalConditions, allergies, injuries, preferences, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
userId,
|
||||
height || null,
|
||||
weight || null,
|
||||
age || null,
|
||||
height,
|
||||
weight,
|
||||
age,
|
||||
gender || null,
|
||||
fitnessGoalsJson,
|
||||
activityLevel || null,
|
||||
medicalConditions || null,
|
||||
allergies || null,
|
||||
injuries || null,
|
||||
exerciseHabits || null,
|
||||
dietHabits || null,
|
||||
preferences || null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
import { fitnessProfileSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const profileData = await request.json();
|
||||
|
||||
if (
|
||||
!profileData.userId ||
|
||||
!profileData.height ||
|
||||
!profileData.weight ||
|
||||
!profileData.age
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, fitnessProfileSchema);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Fitness profile validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const profileData = validation.data;
|
||||
|
||||
// Check if user exists
|
||||
const user = await db.getUserById(profileData.userId);
|
||||
if (!user) {
|
||||
@@ -32,9 +36,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let profile;
|
||||
if (existingProfile) {
|
||||
profile = await db.updateFitnessProfile(profileData.userId, profileData);
|
||||
// Update - omit userId from update data
|
||||
const { userId, ...updateData } = profileData;
|
||||
profile = await db.updateFitnessProfile(userId, updateData as any);
|
||||
} else {
|
||||
profile = await db.createFitnessProfile(profileData);
|
||||
// Create - omit userId since the function signature expects it separately
|
||||
const { userId, ...createData } = profileData;
|
||||
profile = await db.createFitnessProfile({ userId, ...createData } as any);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import log from "@/lib/logger";
|
||||
import {
|
||||
legacyRecommendationSchema,
|
||||
recommendationUpdateRequestSchema,
|
||||
} from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
unauthorizedResponse,
|
||||
forbiddenResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -24,11 +39,11 @@ export async function GET(request: Request) {
|
||||
currentUser?.role === "trainer";
|
||||
|
||||
if (!isStaff) {
|
||||
return new NextResponse("User ID is required", { status: 400 });
|
||||
return badRequestResponse("User ID is required");
|
||||
}
|
||||
|
||||
const recommendations = await db.getAllRecommendations();
|
||||
return NextResponse.json({ recommendations });
|
||||
return successResponse({ recommendations });
|
||||
}
|
||||
|
||||
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
|
||||
@@ -40,7 +55,7 @@ export async function GET(request: Request) {
|
||||
|
||||
if (currentUserId !== targetUserId) {
|
||||
if (!isStaff) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
return forbiddenResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +68,18 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(recommendations);
|
||||
return successResponse(recommendations);
|
||||
} catch (error) {
|
||||
console.error("Error fetching recommendations:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
@@ -75,10 +90,22 @@ export async function POST(request: Request) {
|
||||
currentUser?.role === "trainer";
|
||||
|
||||
if (!isStaff) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
return forbiddenResponse();
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(
|
||||
request,
|
||||
legacyRecommendationSchema,
|
||||
);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Recommendation creation validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
userId,
|
||||
fitnessProfileId,
|
||||
@@ -88,7 +115,7 @@ export async function POST(request: Request) {
|
||||
status,
|
||||
type,
|
||||
content,
|
||||
} = body;
|
||||
} = validation.data;
|
||||
|
||||
// Handle AI Plan (Legacy/Specific)
|
||||
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
|
||||
@@ -101,8 +128,8 @@ export async function POST(request: Request) {
|
||||
activityPlan,
|
||||
dietPlan,
|
||||
status: status || "pending",
|
||||
});
|
||||
return NextResponse.json(recommendation);
|
||||
} as any);
|
||||
return successResponse(recommendation);
|
||||
}
|
||||
|
||||
// Handle User Goal (Generic)
|
||||
@@ -113,31 +140,39 @@ export async function POST(request: Request) {
|
||||
type,
|
||||
recommendationText: content,
|
||||
status: status || "pending",
|
||||
});
|
||||
return NextResponse.json(recommendation);
|
||||
} as any);
|
||||
return successResponse(recommendation);
|
||||
}
|
||||
|
||||
return NextResponse.json("Missing required fields", { status: 400 });
|
||||
return badRequestResponse("Missing required fields");
|
||||
} catch (error) {
|
||||
log.error("Failed to create recommendation", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(
|
||||
request,
|
||||
recommendationUpdateRequestSchema,
|
||||
);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Recommendation update validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, status, recommendationText, activityPlan, dietPlan, content } =
|
||||
body;
|
||||
|
||||
if (!id) {
|
||||
return new NextResponse("Recommendation ID is required", { status: 400 });
|
||||
}
|
||||
validation.data;
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
@@ -147,11 +182,11 @@ export async function PUT(request: Request) {
|
||||
...(content && { recommendationText: content }),
|
||||
...(activityPlan && { activityPlan }),
|
||||
...(dietPlan && { dietPlan }),
|
||||
});
|
||||
} as any);
|
||||
|
||||
return NextResponse.json(updated);
|
||||
return successResponse(updated);
|
||||
} catch (error) {
|
||||
log.error("Failed to update recommendation", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { GET } from "../route";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("@/lib/database/index");
|
||||
jest.mock("@clerk/nextjs/server");
|
||||
jest.mock("@fitai/database", () => ({
|
||||
db: {
|
||||
all: jest.fn(),
|
||||
},
|
||||
sql: jest.fn(),
|
||||
}));
|
||||
jest.mock("@/lib/logger");
|
||||
|
||||
describe("GET /api/users", () => {
|
||||
const mockGetDatabase = require("@/lib/database/index").getDatabase;
|
||||
const mockDb = require("@fitai/database").db;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Batch Query Optimization", () => {
|
||||
it("should use batch methods instead of individual queries", async () => {
|
||||
// Mock database methods
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "user1",
|
||||
email: "user1@test.com",
|
||||
firstName: "User",
|
||||
lastName: "One",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "user2",
|
||||
email: "user2@test.com",
|
||||
firstName: "User",
|
||||
lastName: "Two",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "client1",
|
||||
userId: "user1",
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
},
|
||||
]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
|
||||
// Mock raw DB queries for gym data
|
||||
mockDb.all.mockImplementation(async (query: any) => {
|
||||
const queryStr = query.toString();
|
||||
if (queryStr.includes("users")) {
|
||||
return [
|
||||
{ id: "user1", gym_id: null },
|
||||
{ id: "user2", gym_id: null },
|
||||
];
|
||||
}
|
||||
if (queryStr.includes("gyms")) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Create mock request
|
||||
const request = new NextRequest("http://localhost:3000/api/users");
|
||||
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify batch methods were called
|
||||
expect(mockBatchMethods.getClientsByUserIds).toHaveBeenCalledWith([
|
||||
"user1",
|
||||
"user2",
|
||||
]);
|
||||
expect(mockBatchMethods.getActiveCheckInsByUserIds).toHaveBeenCalledWith([
|
||||
"user1",
|
||||
"user2",
|
||||
]);
|
||||
expect(
|
||||
mockBatchMethods.getAttendanceHistoriesByUserIds,
|
||||
).toHaveBeenCalledWith(["user1", "user2"]);
|
||||
|
||||
// Verify each batch method was called only ONCE
|
||||
expect(mockBatchMethods.getClientsByUserIds).toHaveBeenCalledTimes(1);
|
||||
expect(mockBatchMethods.getActiveCheckInsByUserIds).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
mockBatchMethods.getAttendanceHistoriesByUserIds,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify response structure
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.users).toHaveLength(2);
|
||||
expect(data.data.users[0]).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("should handle empty user list efficiently", async () => {
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
mockDb.all.mockResolvedValue([]);
|
||||
|
||||
const request = new NextRequest("http://localhost:3000/api/users");
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify batch methods were called with empty arrays
|
||||
expect(mockBatchMethods.getClientsByUserIds).toHaveBeenCalledWith([]);
|
||||
expect(mockBatchMethods.getActiveCheckInsByUserIds).toHaveBeenCalledWith(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
mockBatchMethods.getAttendanceHistoriesByUserIds,
|
||||
).toHaveBeenCalledWith([]);
|
||||
|
||||
expect(data.data.users).toEqual([]);
|
||||
});
|
||||
|
||||
it("should filter by role without breaking batch optimization", async () => {
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "user1",
|
||||
email: "user1@test.com",
|
||||
firstName: "User",
|
||||
lastName: "One",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "user2",
|
||||
email: "user2@test.com",
|
||||
firstName: "User",
|
||||
lastName: "Two",
|
||||
role: "admin",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
mockDb.all.mockImplementation(async (query: any) => {
|
||||
const queryStr = query.toString();
|
||||
if (queryStr.includes("users")) {
|
||||
return [
|
||||
{ id: "user1", gym_id: null },
|
||||
{ id: "user2", gym_id: null },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/users?role=client",
|
||||
);
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
// Should still use batch methods for filtered users
|
||||
expect(mockBatchMethods.getClientsByUserIds).toHaveBeenCalledWith([
|
||||
"user1",
|
||||
]);
|
||||
|
||||
expect(data.data.users).toHaveLength(1);
|
||||
expect(data.data.users[0].role).toBe("client");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Response Structure", () => {
|
||||
it("should include client data in response", async () => {
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "user1",
|
||||
email: "user1@test.com",
|
||||
firstName: "User",
|
||||
lastName: "One",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "client1",
|
||||
userId: "user1",
|
||||
membershipType: "premium",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
},
|
||||
]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
mockDb.all.mockImplementation(async (query: any) => {
|
||||
const queryStr = query.toString();
|
||||
if (queryStr.includes("users")) {
|
||||
return [{ id: "user1", gym_id: null }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const request = new NextRequest("http://localhost:3000/api/users");
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.data.users[0].client).toBeDefined();
|
||||
expect(data.data.users[0].client.membershipType).toBe("premium");
|
||||
});
|
||||
|
||||
it("should include attendance statistics", async () => {
|
||||
const weekAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||||
const twoWeeksAgo = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "user1",
|
||||
email: "user1@test.com",
|
||||
firstName: "User",
|
||||
lastName: "One",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "att1",
|
||||
userId: "user1",
|
||||
checkInTime: weekAgo,
|
||||
checkOutTime: new Date(),
|
||||
},
|
||||
{
|
||||
id: "att2",
|
||||
userId: "user1",
|
||||
checkInTime: twoWeeksAgo,
|
||||
checkOutTime: new Date(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
mockDb.all.mockImplementation(async (query: any) => {
|
||||
const queryStr = query.toString();
|
||||
if (queryStr.includes("users")) {
|
||||
return [{ id: "user1", gym_id: null }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const request = new NextRequest("http://localhost:3000/api/users");
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.data.users[0].checkInsThisWeek).toBe(1);
|
||||
expect(data.data.users[0].checkInsThisMonth).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,26 @@ import bcrypt from "bcryptjs";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { db as rawDb, sql } from "@fitai/database";
|
||||
import log from "@/lib/logger";
|
||||
import {
|
||||
roleFilterSchema,
|
||||
userInviteSchema,
|
||||
userUpdateWithIdSchema,
|
||||
} from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validateQueryParams,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
createdResponse,
|
||||
unauthorizedResponse,
|
||||
forbiddenResponse,
|
||||
notFoundResponse,
|
||||
conflictResponse,
|
||||
internalErrorResponse,
|
||||
badRequestResponse,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -52,67 +72,86 @@ export async function GET(request: NextRequest) {
|
||||
: null,
|
||||
});
|
||||
|
||||
const usersWithClients = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
const client = await db.getClientByUserId(user.id);
|
||||
// Batch load all related data at once to avoid N+1 queries
|
||||
const userIds = users.map((u) => u.id);
|
||||
const [allClients, allActiveCheckIns, allAttendanceHistories] =
|
||||
await Promise.all([
|
||||
db.getClientsByUserIds(userIds),
|
||||
db.getActiveCheckInsByUserIds(userIds),
|
||||
db.getAttendanceHistoriesByUserIds(userIds),
|
||||
]);
|
||||
|
||||
// Get active check-in status and statistics for ALL users
|
||||
let isCheckedIn = false;
|
||||
let checkInTime = null;
|
||||
let lastCheckInTime = null;
|
||||
let checkInsThisWeek = 0;
|
||||
let checkInsThisMonth = 0;
|
||||
|
||||
// Query attendance by userId (works for all user types now)
|
||||
const activeCheckIn = await db.getActiveCheckIn(user.id);
|
||||
if (activeCheckIn) {
|
||||
isCheckedIn = true;
|
||||
checkInTime = activeCheckIn.checkInTime;
|
||||
}
|
||||
|
||||
// Get attendance history for statistics
|
||||
const attendanceHistory = await db.getAttendanceHistory(user.id);
|
||||
|
||||
if (attendanceHistory.length > 0) {
|
||||
// Last check-in is the most recent attendance record
|
||||
lastCheckInTime = attendanceHistory[0].checkInTime;
|
||||
|
||||
// Calculate check-ins in last 7 days
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
checkInsThisWeek = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
||||
).length;
|
||||
|
||||
// Calculate check-ins in last 30 days
|
||||
const monthAgo = new Date();
|
||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||
checkInsThisMonth = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
||||
).length;
|
||||
}
|
||||
|
||||
return {
|
||||
...userWithoutPassword,
|
||||
// Override gymId from raw DB hydration to avoid undefined from Drizzle mapping
|
||||
gymId: gymById.get(user.id) ?? (user as any).gymId ?? undefined,
|
||||
// Provide gymName mapped from gyms table
|
||||
gymName: (() => {
|
||||
const gid =
|
||||
gymById.get(user.id) ?? (user as any).gymId ?? undefined;
|
||||
if (!gid) return null;
|
||||
return gymNames.get(gid) ?? null;
|
||||
})(),
|
||||
client,
|
||||
isCheckedIn,
|
||||
checkInTime,
|
||||
lastCheckInTime,
|
||||
checkInsThisWeek,
|
||||
checkInsThisMonth,
|
||||
};
|
||||
}),
|
||||
// Create lookup maps for O(1) access
|
||||
const clientsByUserId = new Map(allClients.map((c) => [c.userId, c]));
|
||||
const activeCheckInsByUserId = new Map(
|
||||
allActiveCheckIns.map((a) => [a.userId, a]),
|
||||
);
|
||||
// Group attendance histories by userId
|
||||
const attendanceByUserId = new Map<string, typeof allAttendanceHistories>();
|
||||
for (const attendance of allAttendanceHistories) {
|
||||
const existing = attendanceByUserId.get(attendance.userId) || [];
|
||||
existing.push(attendance);
|
||||
attendanceByUserId.set(attendance.userId, existing);
|
||||
}
|
||||
|
||||
const usersWithClients = users.map((user) => {
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
const client = clientsByUserId.get(user.id) || null;
|
||||
|
||||
// Get active check-in status and statistics for ALL users
|
||||
let isCheckedIn = false;
|
||||
let checkInTime = null;
|
||||
let lastCheckInTime = null;
|
||||
let checkInsThisWeek = 0;
|
||||
let checkInsThisMonth = 0;
|
||||
|
||||
// Query attendance by userId (works for all user types now)
|
||||
const activeCheckIn = activeCheckInsByUserId.get(user.id);
|
||||
if (activeCheckIn) {
|
||||
isCheckedIn = true;
|
||||
checkInTime = activeCheckIn.checkInTime;
|
||||
}
|
||||
|
||||
// Get attendance history for statistics
|
||||
const attendanceHistory = attendanceByUserId.get(user.id) || [];
|
||||
|
||||
if (attendanceHistory.length > 0) {
|
||||
// Last check-in is the most recent attendance record
|
||||
lastCheckInTime = attendanceHistory[0].checkInTime;
|
||||
|
||||
// Calculate check-ins in last 7 days
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
checkInsThisWeek = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
||||
).length;
|
||||
|
||||
// Calculate check-ins in last 30 days
|
||||
const monthAgo = new Date();
|
||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||
checkInsThisMonth = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
||||
).length;
|
||||
}
|
||||
|
||||
return {
|
||||
...userWithoutPassword,
|
||||
// Override gymId from raw DB hydration to avoid undefined from Drizzle mapping
|
||||
gymId: gymById.get(user.id) ?? (user as any).gymId ?? undefined,
|
||||
// Provide gymName mapped from gyms table
|
||||
gymName: (() => {
|
||||
const gid = gymById.get(user.id) ?? (user as any).gymId ?? undefined;
|
||||
if (!gid) return null;
|
||||
return gymNames.get(gid) ?? null;
|
||||
})(),
|
||||
client,
|
||||
isCheckedIn,
|
||||
checkInTime,
|
||||
lastCheckInTime,
|
||||
checkInsThisWeek,
|
||||
checkInsThisMonth,
|
||||
};
|
||||
});
|
||||
|
||||
log.debug("Prepared response with user details", {
|
||||
usersCount: Array.isArray(usersWithClients) ? usersWithClients.length : 0,
|
||||
@@ -125,13 +164,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return NextResponse.json({ users: usersWithClients });
|
||||
return successResponse({ users: usersWithClients });
|
||||
} catch (error) {
|
||||
log.error("Failed to get users", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +175,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
@@ -162,22 +198,21 @@ export async function POST(request: NextRequest) {
|
||||
const currentUser = await db.getUserById(clerkUserId);
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "Current user not found in database" },
|
||||
{ status: 403 },
|
||||
);
|
||||
return forbiddenResponse("Current user not found in database");
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, firstName, lastName, role, phone } = body;
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, userInviteSchema);
|
||||
|
||||
if (!email || !firstName || !lastName || !role) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!validation.success) {
|
||||
log.warn("User invitation validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const { email, firstName, lastName, role, phone } = validation.data;
|
||||
|
||||
// Enforce Hierarchy
|
||||
const allowed: Record<string, string[]> = {
|
||||
superAdmin: ["admin", "trainer", "client"],
|
||||
@@ -188,19 +223,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const userRole = currentUser.role as keyof typeof allowed;
|
||||
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: `You are not authorized to create a ${role}` },
|
||||
{ status: 403 },
|
||||
);
|
||||
return forbiddenResponse(`You are not authorized to create a ${role}`);
|
||||
}
|
||||
|
||||
// Check if user already exists locally
|
||||
const existingUser = await db.getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "User with this email already exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
return conflictResponse("User with this email already exists");
|
||||
}
|
||||
|
||||
// Create Clerk Invitation
|
||||
@@ -223,18 +252,10 @@ export async function POST(request: NextRequest) {
|
||||
// If user already exists in Clerk, we might want to handle it.
|
||||
// But for now, let's proceed to create local record if invite sent or if they exist.
|
||||
if (clerkError.errors?.[0]?.code === "form_identifier_exists") {
|
||||
return NextResponse.json(
|
||||
{ error: "User already exists in Clerk system" },
|
||||
{ status: 409 },
|
||||
);
|
||||
return conflictResponse("User already exists in Clerk system");
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Failed to send invitation: " +
|
||||
(clerkError.message || "Unknown error"),
|
||||
},
|
||||
{ status: 500 },
|
||||
return internalErrorResponse(
|
||||
"Failed to send invitation: " + (clerkError.message || "Unknown error"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,24 +280,32 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ userId: newUserId.id, message: "Invitation sent" },
|
||||
{ status: 201 },
|
||||
);
|
||||
return createdResponse({
|
||||
userId: newUserId.id,
|
||||
message: "Invitation sent",
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Failed to create user", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const body = await request.json();
|
||||
const { id, email, firstName, lastName, role, phone, gymId } = body;
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(
|
||||
request,
|
||||
userUpdateWithIdSchema,
|
||||
);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("User update validation failed", { errors: validation.errors });
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const { id, email, firstName, lastName, role, phone, gymId } =
|
||||
validation.data;
|
||||
|
||||
log.debug("Updating user", {
|
||||
id,
|
||||
email,
|
||||
@@ -287,31 +316,23 @@ export async function PUT(request: NextRequest) {
|
||||
gymId,
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const db = await getDatabase();
|
||||
|
||||
// Authenticate requester
|
||||
const { userId: requesterId } = await auth();
|
||||
if (!requesterId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
// Fetch requester and target user
|
||||
const requester = await db.getUserById(requesterId);
|
||||
if (!requester) {
|
||||
return NextResponse.json(
|
||||
{ error: "Requester not found" },
|
||||
{ status: 403 },
|
||||
);
|
||||
return forbiddenResponse("Requester not found");
|
||||
}
|
||||
|
||||
const existingUser = await db.getUserById(id);
|
||||
if (!existingUser) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
return notFoundResponse("User not found");
|
||||
}
|
||||
|
||||
// Authorization: determine allowed role changes
|
||||
@@ -325,20 +346,14 @@ export async function PUT(request: NextRequest) {
|
||||
};
|
||||
|
||||
if (role && !allowedByRole[requesterRole]?.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Not authorized to assign role '${role}'` },
|
||||
{ status: 403 },
|
||||
);
|
||||
return forbiddenResponse(`Not authorized to assign role '${role}'`);
|
||||
}
|
||||
|
||||
// Check if email is being changed and if it's already taken
|
||||
if (email && email !== existingUser.email) {
|
||||
const userWithEmail = await db.getUserByEmail(email);
|
||||
if (userWithEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email already in use" },
|
||||
{ status: 409 },
|
||||
);
|
||||
return conflictResponse("Email already in use");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,9 +390,8 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
} catch (clerkErr: any) {
|
||||
log.error("Clerk metadata update failed", clerkErr, { userId: id });
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update role/gym in identity provider" },
|
||||
{ status: 500 },
|
||||
return internalErrorResponse(
|
||||
"Failed to update role/gym in identity provider",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -416,13 +430,10 @@ export async function PUT(request: NextRequest) {
|
||||
};
|
||||
log.debug("Sending updated user response", { updatedUser });
|
||||
|
||||
return NextResponse.json({ user: updatedUser });
|
||||
return successResponse({ user: updatedUser });
|
||||
} catch (error) {
|
||||
log.error("Failed to update user", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,26 +448,20 @@ export async function DELETE(request: NextRequest) {
|
||||
if (ids && Array.isArray(ids)) {
|
||||
// Bulk delete
|
||||
await Promise.all(ids.map((userId: string) => db.deleteUser(userId)));
|
||||
return NextResponse.json({ success: true, deleted: ids.length });
|
||||
return successResponse({ deleted: ids.length });
|
||||
} else if (id) {
|
||||
// Single delete
|
||||
const user = await db.getUserById(id);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
return notFoundResponse("User not found");
|
||||
}
|
||||
await db.deleteUser(id);
|
||||
return NextResponse.json({ success: true });
|
||||
return successResponse({ deleted: 1 });
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID or IDs array required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return badRequestResponse("User ID or IDs array required");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete user(s)", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
@@ -20,81 +20,47 @@ export function MembershipDistributionChart({
|
||||
title = "Membership Distribution",
|
||||
}: MembershipDistributionChartProps) {
|
||||
const chartOptions: AgChartOptions = useMemo(
|
||||
() => ({
|
||||
title: {
|
||||
text: title,
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
data,
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
calloutLabelKey: "label",
|
||||
angleKey: "value",
|
||||
sectorLabelKey: "label",
|
||||
fills: data.map((item) => item.color || "#3b82f6"),
|
||||
strokes: ["#ffffff"],
|
||||
strokeWidth: 2,
|
||||
calloutLabel: {
|
||||
enabled: true,
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
sectorLabel: {
|
||||
enabled: true,
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
color: "#ffffff",
|
||||
formatter: (params: any) => {
|
||||
const percentage = (
|
||||
(params.datum.value /
|
||||
data.reduce((sum, item) => sum + item.value, 0)) *
|
||||
100
|
||||
).toFixed(1);
|
||||
return `${params.datum.label}: ${percentage}%`;
|
||||
},
|
||||
},
|
||||
highlightStyle: {
|
||||
item: {
|
||||
fillOpacity: 0.8,
|
||||
stroke: "#000000",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
renderer: (params: any) => {
|
||||
const percentage = (
|
||||
(params.datum.value /
|
||||
data.reduce((sum, item) => sum + item.value, 0)) *
|
||||
100
|
||||
).toFixed(1);
|
||||
return `<div class="bg-white p-2 rounded shadow-lg border">
|
||||
<div class="font-bold">${params.datum.label}</div>
|
||||
<div class="text-sm">Count: ${params.datum.value}</div>
|
||||
<div class="text-sm">Percentage: ${percentage}%</div>
|
||||
</div>`;
|
||||
},
|
||||
},
|
||||
() =>
|
||||
({
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
],
|
||||
legend: {
|
||||
enabled: true,
|
||||
position: "right",
|
||||
fontSize: 12,
|
||||
marker: {
|
||||
shape: "square",
|
||||
size: 12,
|
||||
data,
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
calloutLabelKey: "label",
|
||||
angleKey: "value",
|
||||
sectorLabelKey: "label",
|
||||
fills: data.map((item) => item.color || "#3b82f6"),
|
||||
calloutLabel: {
|
||||
enabled: true,
|
||||
},
|
||||
sectorLabel: {
|
||||
enabled: true,
|
||||
color: "#ffffff",
|
||||
formatter: (params: any) => {
|
||||
const percentage = (
|
||||
(params.datum.value /
|
||||
data.reduce((sum, item) => sum + item.value, 0)) *
|
||||
100
|
||||
).toFixed(1);
|
||||
return `${percentage}%`;
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
renderer: (params: any) => ({
|
||||
title: params.datum.label,
|
||||
content: `${params.datum.value} (${((params.datum.value / data.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1)}%)`,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
legend: {
|
||||
enabled: true,
|
||||
position: "right",
|
||||
},
|
||||
},
|
||||
padding: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
},
|
||||
}),
|
||||
}) as any,
|
||||
[data, title],
|
||||
);
|
||||
|
||||
|
||||
@@ -20,84 +20,52 @@ export function RevenueChart({
|
||||
title = "Monthly Revenue",
|
||||
}: RevenueChartProps) {
|
||||
const chartOptions: AgChartOptions = useMemo(
|
||||
() => ({
|
||||
title: {
|
||||
text: title,
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
data,
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
xKey: "category",
|
||||
yKey: "value",
|
||||
fills: data.map((item) => item.color || "#10b981"),
|
||||
strokes: ["#ffffff"],
|
||||
strokeWidth: 2,
|
||||
cornerRadius: 4,
|
||||
highlightStyle: {
|
||||
item: {
|
||||
fill: "#059669",
|
||||
stroke: "#ffffff",
|
||||
strokeWidth: 2,
|
||||
() =>
|
||||
({
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
data,
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
xKey: "category",
|
||||
yKey: "value",
|
||||
fill: "#10b981",
|
||||
stroke: "#ffffff",
|
||||
strokeWidth: 2,
|
||||
cornerRadius: 4,
|
||||
highlightStyle: {
|
||||
item: {
|
||||
fill: "#059669",
|
||||
},
|
||||
},
|
||||
label: {
|
||||
placement: "outside",
|
||||
formatter: (params: any) => `$${params.value.toLocaleString()}`,
|
||||
},
|
||||
tooltip: {
|
||||
renderer: (params: any) => ({
|
||||
title: params.datum.category,
|
||||
content: `Revenue: $${params.datum.value.toLocaleString()}`,
|
||||
}),
|
||||
},
|
||||
},
|
||||
label: {
|
||||
enabled: true,
|
||||
position: "top",
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
color: "#374151",
|
||||
formatter: (params: any) => `$${params.value.toLocaleString()}`,
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
type: "category",
|
||||
position: "bottom",
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
renderer: (params: any) => {
|
||||
return `<div class="bg-white p-2 rounded shadow-lg border">
|
||||
<div class="font-bold">${params.datum.category}</div>
|
||||
<div class="text-sm">Revenue: $${params.datum.value.toLocaleString()}</div>
|
||||
</div>`;
|
||||
{
|
||||
type: "number",
|
||||
position: "left",
|
||||
label: {
|
||||
formatter: (params: any) => `$${params.value.toLocaleString()}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
type: "category",
|
||||
position: "bottom",
|
||||
title: {
|
||||
text: "Month",
|
||||
fontSize: 14,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
rotation: 45,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
position: "left",
|
||||
title: {
|
||||
text: "Revenue ($)",
|
||||
fontSize: 14,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
formatter: (params: any) => `$${params.value.toLocaleString()}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
legend: {
|
||||
enabled: false,
|
||||
},
|
||||
padding: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 60,
|
||||
left: 80,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}) as any,
|
||||
[data, title],
|
||||
);
|
||||
|
||||
|
||||
@@ -20,63 +20,38 @@ export function UserGrowthChart({
|
||||
title = "User Growth",
|
||||
}: UserGrowthChartProps) {
|
||||
const chartOptions: AgChartOptions = useMemo(
|
||||
() => ({
|
||||
title: {
|
||||
text: title,
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
data,
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
xKey: "label",
|
||||
yKey: "value",
|
||||
stroke: "#3b82f6",
|
||||
strokeWidth: 3,
|
||||
marker: {
|
||||
size: 6,
|
||||
fill: "#3b82f6",
|
||||
stroke: "#ffffff",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
highlightStyle: {
|
||||
item: {
|
||||
fill: "#1d4ed8",
|
||||
() =>
|
||||
({
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
data,
|
||||
series: [
|
||||
{
|
||||
type: "line",
|
||||
xKey: "label",
|
||||
yKey: "value",
|
||||
stroke: "#3b82f6",
|
||||
strokeWidth: 3,
|
||||
marker: {
|
||||
size: 6,
|
||||
fill: "#3b82f6",
|
||||
stroke: "#ffffff",
|
||||
strokeWidth: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
type: "category",
|
||||
position: "bottom",
|
||||
title: {
|
||||
text: "Time Period",
|
||||
fontSize: 14,
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
type: "category",
|
||||
position: "bottom",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
position: "left",
|
||||
title: {
|
||||
text: "Number of Users",
|
||||
fontSize: 14,
|
||||
{
|
||||
type: "number",
|
||||
position: "left",
|
||||
},
|
||||
},
|
||||
],
|
||||
legend: {
|
||||
enabled: false,
|
||||
},
|
||||
padding: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}) as any,
|
||||
[data, title],
|
||||
);
|
||||
|
||||
|
||||
@@ -4,113 +4,128 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
CalendarCheck,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
Brain,
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
CalendarCheck,
|
||||
CreditCard,
|
||||
Settings,
|
||||
LogOut,
|
||||
Brain,
|
||||
} from "lucide-react";
|
||||
import { UserButton, useUser } from "@clerk/nextjs";
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
status: string;
|
||||
id: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { user } = useUser();
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const pathname = usePathname();
|
||||
const { user } = useUser();
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPending = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/recommendations");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const pending = (data.recommendations || []).filter(
|
||||
(r: Recommendation) => r.status === "pending"
|
||||
);
|
||||
setPendingCount(pending.length);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch pending recommendations", e);
|
||||
}
|
||||
};
|
||||
fetchPending();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
|
||||
{ icon: Users, label: "Users", href: "/users" },
|
||||
{
|
||||
icon: Brain,
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
AI Recommendations
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-2 inline-block bg-red-600 text-xs rounded-full px-2 py-0.5">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
href: "/recommendations",
|
||||
},
|
||||
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
|
||||
{ icon: CreditCard, label: "Payments", href: "/payments" },
|
||||
{ icon: Settings, label: "Settings", href: "/settings" },
|
||||
];
|
||||
useEffect(() => {
|
||||
const fetchPending = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/recommendations");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const pending = (data.recommendations || []).filter(
|
||||
(r: Recommendation) => r.status === "pending",
|
||||
);
|
||||
setPendingCount(pending.length);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch pending recommendations", e);
|
||||
}
|
||||
};
|
||||
fetchPending();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<aside className="w-20 hover:w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800 transition-all duration-300 z-50 group overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center overflow-hidden whitespace-nowrap">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
FitAI Admin
|
||||
</h1>
|
||||
</div>
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Dashboard", href: "/" },
|
||||
{ icon: Users, label: "Users", href: "/users" },
|
||||
{
|
||||
icon: Brain,
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
AI Recommendations
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-2 inline-block bg-red-600 text-xs rounded-full px-2 py-0.5">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
href: "/recommendations",
|
||||
},
|
||||
{ icon: CalendarCheck, label: "Attendance", href: "/attendance" },
|
||||
{ icon: CreditCard, label: "Payments", href: "/payments" },
|
||||
{ icon: Settings, label: "Settings", href: "/settings" },
|
||||
];
|
||||
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
const label = typeof item.label === "string" ? item.label : item.label;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 ${isActive
|
||||
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"}`}
|
||||
>
|
||||
<div className="min-w-[20px]">
|
||||
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
|
||||
</div>
|
||||
<span className="font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-300 whitespace-nowrap overflow-hidden">
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
return (
|
||||
<aside className="w-20 hover:w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800 transition-all duration-300 z-50 group overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center overflow-hidden whitespace-nowrap">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
FitAI Admin
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-slate-800">
|
||||
<div className="flex items-center gap-3 px-2 py-3 rounded-lg bg-slate-800/50 overflow-hidden whitespace-nowrap">
|
||||
<div className="min-w-[32px]">
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user?.fullName || "Admin User"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 truncate">
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{menuItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href;
|
||||
const label =
|
||||
typeof item.label === "string" ? item.label : item.label;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 ${
|
||||
isActive
|
||||
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-[20px]">
|
||||
<Icon
|
||||
size={20}
|
||||
className={
|
||||
isActive
|
||||
? "text-white"
|
||||
: "text-slate-500 group-hover:text-white"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-300 whitespace-nowrap overflow-hidden">
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-800">
|
||||
<div className="flex items-center gap-3 px-2 py-3 rounded-lg bg-slate-800/50 overflow-hidden whitespace-nowrap">
|
||||
<div className="min-w-[32px]">
|
||||
{mounted && <UserButton afterSignOutUrl="/" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user?.fullName || "Admin User"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 truncate">
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -272,7 +272,7 @@ export function UserGrid({
|
||||
flex: 1,
|
||||
resizable: true,
|
||||
floatingFilter: true,
|
||||
suppressMenu: true,
|
||||
suppressHeaderMenuButton: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -284,7 +284,10 @@ export function UserGrid({
|
||||
columnDefs,
|
||||
defaultColDef,
|
||||
rowData: users,
|
||||
rowSelection: "multiple" as const,
|
||||
rowSelection: {
|
||||
mode: "multiRow" as const,
|
||||
enableClickSelection: true,
|
||||
},
|
||||
onSelectionChanged: () => {
|
||||
const selectedNodes = gridRef.current?.api.getSelectedNodes();
|
||||
const selectedData =
|
||||
@@ -295,7 +298,6 @@ export function UserGrid({
|
||||
onUserSelect(selectedData[0]);
|
||||
}
|
||||
},
|
||||
suppressRowClickSelection: false,
|
||||
animateRows: true,
|
||||
loading: loading,
|
||||
pagination: true,
|
||||
|
||||
@@ -91,7 +91,8 @@ export function UserManagement() {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
});
|
||||
const data = await response.json();
|
||||
const responseData = await response.json();
|
||||
const data = responseData.data || responseData; // Handle both old and new API response formats
|
||||
log.debug("Received users data", {
|
||||
count: Array.isArray(data.users) ? data.users.length : 0,
|
||||
sample:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* API Response Utilities
|
||||
*
|
||||
* Standardized response formatting for FitAI Admin API
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./responses";
|
||||
@@ -0,0 +1,364 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
ApiSuccessResponse,
|
||||
ApiErrorResponse,
|
||||
PaginatedResponse,
|
||||
ResponseMeta,
|
||||
PaginationMeta,
|
||||
ApiErrorCode,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* API Response Helpers for FitAI Admin API
|
||||
*
|
||||
* Provides consistent response formatting across all API endpoints.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a unique request ID
|
||||
*/
|
||||
function generateRequestId(): string {
|
||||
return `req_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create response metadata
|
||||
*/
|
||||
function createMeta(options?: {
|
||||
requestId?: string;
|
||||
pagination?: PaginationMeta;
|
||||
}): ResponseMeta {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
requestId: options?.requestId || generateRequestId(),
|
||||
...(options?.pagination && { pagination: options.pagination }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized success response
|
||||
*
|
||||
* @param data - The response data
|
||||
* @param options - Optional metadata configuration
|
||||
* @returns NextResponse with standardized format
|
||||
*
|
||||
* @example
|
||||
* return successResponse({ user: { id: '123', email: 'user@example.com' } });
|
||||
*
|
||||
* @example
|
||||
* return successResponse({ message: 'User created' }, { status: 201 });
|
||||
*/
|
||||
export function successResponse<T>(
|
||||
data: T,
|
||||
options?: {
|
||||
status?: number;
|
||||
requestId?: string;
|
||||
headers?: HeadersInit;
|
||||
},
|
||||
): NextResponse<ApiSuccessResponse<T>> {
|
||||
const response: ApiSuccessResponse<T> = {
|
||||
success: true,
|
||||
data,
|
||||
meta: createMeta({ requestId: options?.requestId }),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: options?.status || 200,
|
||||
headers: options?.headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized paginated response
|
||||
*
|
||||
* @param data - Array of items to return
|
||||
* @param pagination - Pagination metadata
|
||||
* @param options - Optional configuration
|
||||
* @returns NextResponse with paginated format
|
||||
*
|
||||
* @example
|
||||
* const users = await db.getUsers();
|
||||
* return paginatedResponse(users, {
|
||||
* page: 1,
|
||||
* pageSize: 20,
|
||||
* totalItems: 100,
|
||||
* totalPages: 5,
|
||||
* hasNextPage: true,
|
||||
* hasPreviousPage: false,
|
||||
* });
|
||||
*/
|
||||
export function paginatedResponse<T>(
|
||||
data: T[],
|
||||
pagination: PaginationMeta,
|
||||
options?: {
|
||||
requestId?: string;
|
||||
headers?: HeadersInit;
|
||||
},
|
||||
): NextResponse<PaginatedResponse<T>> {
|
||||
const response: PaginatedResponse<T> = {
|
||||
success: true,
|
||||
data,
|
||||
meta: {
|
||||
...createMeta({ requestId: options?.requestId }),
|
||||
pagination,
|
||||
},
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: 200,
|
||||
headers: options?.headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized error response
|
||||
*
|
||||
* @param message - Error message
|
||||
* @param options - Error configuration
|
||||
* @returns NextResponse with error format
|
||||
*
|
||||
* @example
|
||||
* return errorResponse('User not found', {
|
||||
* status: 404,
|
||||
* code: ApiErrorCode.NOT_FOUND,
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* return errorResponse('Validation failed', {
|
||||
* status: 400,
|
||||
* code: ApiErrorCode.VALIDATION_ERROR,
|
||||
* details: { email: ['Invalid email format'] },
|
||||
* });
|
||||
*/
|
||||
export function errorResponse(
|
||||
message: string,
|
||||
options?: {
|
||||
status?: number;
|
||||
code?: ApiErrorCode | string;
|
||||
details?: Record<string, string[]>;
|
||||
requestId?: string;
|
||||
headers?: HeadersInit;
|
||||
},
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
const response: ApiErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
message,
|
||||
code: options?.code,
|
||||
details: options?.details,
|
||||
},
|
||||
meta: createMeta({ requestId: options?.requestId }),
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: options?.status || 500,
|
||||
headers: options?.headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 400 Bad Request response
|
||||
*/
|
||||
export function badRequestResponse(
|
||||
message: string = "Bad request",
|
||||
details?: Record<string, string[]>,
|
||||
headers?: HeadersInit,
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, {
|
||||
status: 400,
|
||||
code: ApiErrorCode.INVALID_INPUT,
|
||||
details,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 401 Unauthorized response
|
||||
*/
|
||||
export function unauthorizedResponse(
|
||||
message: string = "Unauthorized",
|
||||
headers?: HeadersInit,
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, {
|
||||
status: 401,
|
||||
code: ApiErrorCode.UNAUTHORIZED,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 403 Forbidden response
|
||||
*/
|
||||
export function forbiddenResponse(
|
||||
message: string = "Forbidden",
|
||||
headers?: HeadersInit,
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, {
|
||||
status: 403,
|
||||
code: ApiErrorCode.FORBIDDEN,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 404 Not Found response
|
||||
*/
|
||||
export function notFoundResponse(
|
||||
message: string = "Resource not found",
|
||||
headers?: HeadersInit,
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, {
|
||||
status: 404,
|
||||
code: ApiErrorCode.NOT_FOUND,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 409 Conflict response
|
||||
*/
|
||||
export function conflictResponse(
|
||||
message: string = "Resource conflict",
|
||||
headers?: HeadersInit,
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, {
|
||||
status: 409,
|
||||
code: ApiErrorCode.CONFLICT,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 500 Internal Server Error response
|
||||
*/
|
||||
export function internalErrorResponse(
|
||||
message: string = "Internal server error",
|
||||
headers?: HeadersInit,
|
||||
): NextResponse<ApiErrorResponse> {
|
||||
return errorResponse(message, {
|
||||
status: 500,
|
||||
code: ApiErrorCode.INTERNAL_ERROR,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate pagination metadata from array and page parameters
|
||||
*
|
||||
* @param totalItems - Total number of items
|
||||
* @param page - Current page number (1-indexed)
|
||||
* @param pageSize - Number of items per page
|
||||
* @returns Pagination metadata
|
||||
*
|
||||
* @example
|
||||
* const pagination = calculatePagination(100, 1, 20);
|
||||
* // { page: 1, pageSize: 20, totalItems: 100, totalPages: 5, hasNextPage: true, hasPreviousPage: false }
|
||||
*/
|
||||
export function calculatePagination(
|
||||
totalItems: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): PaginationMeta {
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
totalItems,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate an array of items
|
||||
*
|
||||
* @param items - Array to paginate
|
||||
* @param page - Current page (1-indexed)
|
||||
* @param pageSize - Items per page
|
||||
* @returns Paginated slice of items and metadata
|
||||
*
|
||||
* @example
|
||||
* const users = await db.getAllUsers();
|
||||
* const { items, pagination } = paginateArray(users, 1, 20);
|
||||
* return paginatedResponse(items, pagination);
|
||||
*/
|
||||
export function paginateArray<T>(
|
||||
items: T[],
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): {
|
||||
items: T[];
|
||||
pagination: PaginationMeta;
|
||||
} {
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedItems = items.slice(startIndex, endIndex);
|
||||
|
||||
return {
|
||||
items: paginatedItems,
|
||||
pagination: calculatePagination(items.length, page, pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CORS headers to response
|
||||
*
|
||||
* @param headers - Existing headers or undefined
|
||||
* @returns Headers with CORS added
|
||||
*/
|
||||
export function addCorsHeaders(headers?: HeadersInit): HeadersInit {
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
if (!headers) {
|
||||
return corsHeaders;
|
||||
}
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
const newHeaders = new Headers(headers);
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => {
|
||||
newHeaders.set(key, value);
|
||||
});
|
||||
return newHeaders;
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
return [...headers, ...Object.entries(corsHeaders)];
|
||||
}
|
||||
|
||||
return { ...headers, ...corsHeaders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a 204 No Content response
|
||||
*/
|
||||
export function noContentResponse(
|
||||
headers?: HeadersInit,
|
||||
): NextResponse<undefined> {
|
||||
return new NextResponse(undefined, {
|
||||
status: 204,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized created response (201)
|
||||
*/
|
||||
export function createdResponse<T>(
|
||||
data: T,
|
||||
options?: {
|
||||
requestId?: string;
|
||||
headers?: HeadersInit;
|
||||
},
|
||||
): NextResponse<ApiSuccessResponse<T>> {
|
||||
return successResponse(data, {
|
||||
status: 201,
|
||||
requestId: options?.requestId,
|
||||
headers: options?.headers,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Standardized API Response Types for FitAI Admin API
|
||||
*
|
||||
* These types ensure consistent response structures across all API endpoints.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-specific fields
|
||||
client?: ClientResponse | null;
|
||||
// Attendance fields
|
||||
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",
|
||||
}
|
||||
@@ -1,181 +1,547 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { DrizzleDatabase } from '../drizzle'
|
||||
import { DatabaseConfig } from '../types'
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from '@fitai/database'
|
||||
import { DrizzleDatabase } from "../drizzle";
|
||||
import { DatabaseConfig } from "../types";
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "@fitai/database";
|
||||
|
||||
describe('DrizzleDatabase', () => {
|
||||
let db: DrizzleDatabase
|
||||
let sqlite: Database.Database
|
||||
let drizzleDb: ReturnType<typeof drizzle>
|
||||
describe("DrizzleDatabase", () => {
|
||||
let db: DrizzleDatabase;
|
||||
let sqlite: Database.Database;
|
||||
let drizzleDb: ReturnType<typeof drizzle>;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create in-memory SQLite database
|
||||
sqlite = new Database(':memory:')
|
||||
drizzleDb = drizzle(sqlite, { schema })
|
||||
beforeEach(async () => {
|
||||
// Create in-memory SQLite database
|
||||
sqlite = new Database(":memory:");
|
||||
drizzleDb = drizzle(sqlite, { schema });
|
||||
|
||||
const config: DatabaseConfig = {
|
||||
type: 'sqlite',
|
||||
connection: { filename: ':memory:' },
|
||||
options: { logging: false }
|
||||
const config: DatabaseConfig = {
|
||||
type: "sqlite",
|
||||
connection: { filename: ":memory:" },
|
||||
options: { logging: false },
|
||||
};
|
||||
|
||||
// Initialize DrizzleDatabase with the in-memory db
|
||||
db = new DrizzleDatabase(config, drizzleDb as any);
|
||||
await db.connect();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.disconnect();
|
||||
sqlite.close();
|
||||
});
|
||||
|
||||
describe("User Operations", () => {
|
||||
it("should create and retrieve a user", async () => {
|
||||
const newUser = {
|
||||
email: "test@example.com",
|
||||
firstName: "Test",
|
||||
lastName: "User",
|
||||
password: "password123",
|
||||
role: "client" as const,
|
||||
};
|
||||
|
||||
const createdUser = await db.createUser(newUser);
|
||||
expect(createdUser).toBeDefined();
|
||||
expect(createdUser.id).toBeDefined();
|
||||
expect(createdUser.email).toBe(newUser.email);
|
||||
|
||||
const fetchedUser = await db.getUserById(createdUser.id);
|
||||
expect(fetchedUser).toBeDefined();
|
||||
expect(fetchedUser?.id).toBe(createdUser.id);
|
||||
expect(fetchedUser?.email).toBe(createdUser.email);
|
||||
// SQLite/Drizzle might truncate ms or handle dates differently in test env
|
||||
expect(new Date(fetchedUser!.createdAt).getTime()).toBeLessThanOrEqual(
|
||||
createdUser.createdAt.getTime(),
|
||||
);
|
||||
expect(new Date(fetchedUser!.updatedAt).getTime()).toBeLessThanOrEqual(
|
||||
createdUser.updatedAt.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should update a user", async () => {
|
||||
const user = await db.createUser({
|
||||
email: "update@example.com",
|
||||
firstName: "Update",
|
||||
lastName: "Me",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
const updated = await db.updateUser(user.id, { firstName: "Updated" });
|
||||
expect(updated?.firstName).toBe("Updated");
|
||||
|
||||
const fetched = await db.getUserById(user.id);
|
||||
expect(fetched?.firstName).toBe("Updated");
|
||||
});
|
||||
|
||||
it("should delete a user", async () => {
|
||||
const user = await db.createUser({
|
||||
email: "delete@example.com",
|
||||
firstName: "Delete",
|
||||
lastName: "Me",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
const deleted = await db.deleteUser(user.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const fetched = await db.getUserById(user.id);
|
||||
expect(fetched).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Client Operations", () => {
|
||||
it("should create and retrieve a client", async () => {
|
||||
const user = await db.createUser({
|
||||
email: "client@example.com",
|
||||
firstName: "Client",
|
||||
lastName: "User",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
const newClient = {
|
||||
userId: user.id,
|
||||
membershipType: "basic" as const,
|
||||
membershipStatus: "active" as const,
|
||||
joinDate: new Date(),
|
||||
};
|
||||
|
||||
const createdClient = await db.createClient(newClient);
|
||||
expect(createdClient).toBeDefined();
|
||||
expect(createdClient.userId).toBe(user.id);
|
||||
|
||||
const fetchedClient = await db.getClientById(createdClient.id);
|
||||
expect(fetchedClient).toBeDefined();
|
||||
expect(fetchedClient?.id).toBe(createdClient.id);
|
||||
expect(fetchedClient?.userId).toBe(createdClient.userId);
|
||||
expect(fetchedClient?.membershipType).toBe(createdClient.membershipType);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fitness Profile Operations", () => {
|
||||
it("should create and retrieve a fitness profile", async () => {
|
||||
const user = await db.createUser({
|
||||
email: "profile@example.com",
|
||||
firstName: "Profile",
|
||||
lastName: "User",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
const newProfile = {
|
||||
userId: user.id,
|
||||
height: 180,
|
||||
weight: 75,
|
||||
age: 30,
|
||||
gender: "male" as const,
|
||||
activityLevel: "moderately_active" as const,
|
||||
fitnessGoals: ["weight_loss"],
|
||||
exerciseHabits: "None",
|
||||
dietHabits: "None",
|
||||
medicalConditions: "None",
|
||||
};
|
||||
|
||||
const createdProfile = await db.createFitnessProfile(newProfile as any);
|
||||
expect(createdProfile).toBeDefined();
|
||||
expect(createdProfile.userId).toBe(user.id);
|
||||
|
||||
const fetchedProfile = await db.getFitnessProfileByUserId(user.id);
|
||||
expect(fetchedProfile).toBeDefined();
|
||||
expect(fetchedProfile?.userId).toBe(user.id);
|
||||
expect(fetchedProfile?.fitnessGoals).toEqual(
|
||||
expect.arrayContaining(["weight_loss"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attendance Operations", () => {
|
||||
it("should check in and check out", async () => {
|
||||
const user = await db.createUser({
|
||||
email: "attendance@example.com",
|
||||
firstName: "Attendance",
|
||||
lastName: "User",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
const checkIn = await db.checkIn(user.id, "gym");
|
||||
expect(checkIn).toBeDefined();
|
||||
expect(checkIn.checkOutTime).toBeUndefined();
|
||||
|
||||
const activeCheckIn = await db.getActiveCheckIn(user.id);
|
||||
expect(activeCheckIn).toBeDefined();
|
||||
expect(activeCheckIn?.id).toBe(checkIn.id);
|
||||
|
||||
const checkOut = await db.checkOut(checkIn.id);
|
||||
expect(checkOut?.checkOutTime).toBeDefined();
|
||||
|
||||
const activeCheckInAfter = await db.getActiveCheckIn(user.id);
|
||||
expect(activeCheckInAfter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Batch Query Operations (Phase 9)", () => {
|
||||
describe("getClientsByUserIds", () => {
|
||||
it("should return clients for multiple users", async () => {
|
||||
// Create users
|
||||
const user1 = await db.createUser({
|
||||
email: "batch1@example.com",
|
||||
firstName: "Batch",
|
||||
lastName: "User1",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
const user2 = await db.createUser({
|
||||
email: "batch2@example.com",
|
||||
firstName: "Batch",
|
||||
lastName: "User2",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
const user3 = await db.createUser({
|
||||
email: "batch3@example.com",
|
||||
firstName: "Batch",
|
||||
lastName: "User3",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
// Create clients for user1 and user2 only
|
||||
await db.createClient({
|
||||
userId: user1.id,
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
});
|
||||
await db.createClient({
|
||||
userId: user2.id,
|
||||
membershipType: "premium",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
});
|
||||
|
||||
// Batch load clients
|
||||
const clients = await db.getClientsByUserIds([
|
||||
user1.id,
|
||||
user2.id,
|
||||
user3.id,
|
||||
]);
|
||||
|
||||
expect(clients).toHaveLength(2);
|
||||
expect(clients.map((c) => c.userId).sort()).toEqual(
|
||||
[user1.id, user2.id].sort(),
|
||||
);
|
||||
expect(clients.find((c) => c.userId === user3.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", async () => {
|
||||
const clients = await db.getClientsByUserIds([]);
|
||||
expect(clients).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array when no clients exist for users", async () => {
|
||||
const clients = await db.getClientsByUserIds(["nonexistent-id"]);
|
||||
expect(clients).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttendanceHistoriesByUserIds", () => {
|
||||
it("should return attendance records for multiple users", async () => {
|
||||
const user1 = await db.createUser({
|
||||
email: "attendance1@example.com",
|
||||
firstName: "Attendance",
|
||||
lastName: "User1",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
const user2 = await db.createUser({
|
||||
email: "attendance2@example.com",
|
||||
firstName: "Attendance",
|
||||
lastName: "User2",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
// Create attendance records
|
||||
await db.checkIn(user1.id, "gym");
|
||||
await db.checkIn(user1.id, "gym");
|
||||
await db.checkIn(user2.id, "gym");
|
||||
|
||||
const attendances = await db.getAttendanceHistoriesByUserIds([
|
||||
user1.id,
|
||||
user2.id,
|
||||
]);
|
||||
|
||||
expect(attendances.length).toBeGreaterThanOrEqual(3);
|
||||
const user1Records = attendances.filter((a) => a.userId === user1.id);
|
||||
const user2Records = attendances.filter((a) => a.userId === user2.id);
|
||||
expect(user1Records.length).toBeGreaterThanOrEqual(2);
|
||||
expect(user2Records.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should return records ordered by checkInTime DESC", async () => {
|
||||
const user = await db.createUser({
|
||||
email: "order@example.com",
|
||||
firstName: "Order",
|
||||
lastName: "Test",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
// Create multiple check-ins with slight delays
|
||||
const checkIn1 = await db.checkIn(user.id, "gym");
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
const checkIn2 = await db.checkIn(user.id, "gym");
|
||||
|
||||
const attendances = await db.getAttendanceHistoriesByUserIds([user.id]);
|
||||
|
||||
expect(attendances.length).toBeGreaterThanOrEqual(2);
|
||||
// Most recent should be first
|
||||
expect(
|
||||
new Date(attendances[0].checkInTime).getTime(),
|
||||
).toBeGreaterThanOrEqual(
|
||||
new Date(attendances[1].checkInTime).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", async () => {
|
||||
const attendances = await db.getAttendanceHistoriesByUserIds([]);
|
||||
expect(attendances).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveCheckInsByUserIds", () => {
|
||||
it("should return only active check-ins", async () => {
|
||||
const user1 = await db.createUser({
|
||||
email: "active1@example.com",
|
||||
firstName: "Active",
|
||||
lastName: "User1",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
const user2 = await db.createUser({
|
||||
email: "active2@example.com",
|
||||
firstName: "Active",
|
||||
lastName: "User2",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
// User1: check in and check out
|
||||
const checkIn1 = await db.checkIn(user1.id, "gym");
|
||||
await db.checkOut(checkIn1.id);
|
||||
|
||||
// User2: check in only (active)
|
||||
const checkIn2 = await db.checkIn(user2.id, "gym");
|
||||
|
||||
const activeCheckIns = await db.getActiveCheckInsByUserIds([
|
||||
user1.id,
|
||||
user2.id,
|
||||
]);
|
||||
|
||||
expect(activeCheckIns).toHaveLength(1);
|
||||
expect(activeCheckIns[0].userId).toBe(user2.id);
|
||||
expect(activeCheckIns[0].checkOutTime).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return empty array when no active check-ins exist", async () => {
|
||||
const user = await db.createUser({
|
||||
email: "noactive@example.com",
|
||||
firstName: "NoActive",
|
||||
lastName: "User",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
const checkIn = await db.checkIn(user.id, "gym");
|
||||
await db.checkOut(checkIn.id);
|
||||
|
||||
const activeCheckIns = await db.getActiveCheckInsByUserIds([user.id]);
|
||||
expect(activeCheckIns).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", async () => {
|
||||
const activeCheckIns = await db.getActiveCheckInsByUserIds([]);
|
||||
expect(activeCheckIns).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecommendationsByUserIds", () => {
|
||||
it("should return recommendations for multiple users", async () => {
|
||||
const user1 = await db.createUser({
|
||||
email: "rec1@example.com",
|
||||
firstName: "Rec",
|
||||
lastName: "User1",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
const user2 = await db.createUser({
|
||||
email: "rec2@example.com",
|
||||
firstName: "Rec",
|
||||
lastName: "User2",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
// Create recommendations
|
||||
// Create fitness profiles (required for recommendations)
|
||||
const profile1 = await db.createFitnessProfile({
|
||||
userId: user1.id,
|
||||
height: 180,
|
||||
weight: 75,
|
||||
age: 30,
|
||||
gender: "male",
|
||||
activityLevel: "moderately_active",
|
||||
} as any);
|
||||
|
||||
const profile2 = await db.createFitnessProfile({
|
||||
userId: user2.id,
|
||||
height: 165,
|
||||
weight: 60,
|
||||
age: 25,
|
||||
gender: "female",
|
||||
activityLevel: "lightly_active",
|
||||
} as any);
|
||||
|
||||
await db.createRecommendation({
|
||||
userId: user1.id,
|
||||
fitnessProfileId: profile1.id,
|
||||
recommendationText: "Test recommendation 1",
|
||||
activityPlan: "Activity plan 1",
|
||||
dietPlan: "Diet plan 1",
|
||||
status: "pending",
|
||||
} as any);
|
||||
await db.createRecommendation({
|
||||
userId: user2.id,
|
||||
fitnessProfileId: profile2.id,
|
||||
recommendationText: "Test recommendation 2",
|
||||
activityPlan: "Activity plan 2",
|
||||
dietPlan: "Diet plan 2",
|
||||
status: "pending",
|
||||
} as any);
|
||||
|
||||
const recommendations = await db.getRecommendationsByUserIds([
|
||||
user1.id,
|
||||
user2.id,
|
||||
]);
|
||||
|
||||
expect(recommendations.length).toBeGreaterThanOrEqual(2);
|
||||
const user1Recs = recommendations.filter((r) => r.userId === user1.id);
|
||||
const user2Recs = recommendations.filter((r) => r.userId === user2.id);
|
||||
expect(user1Recs.length).toBeGreaterThanOrEqual(1);
|
||||
expect(user2Recs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", async () => {
|
||||
const recommendations = await db.getRecommendationsByUserIds([]);
|
||||
expect(recommendations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFitnessGoalsByUserIds", () => {
|
||||
it("should return fitness goals for multiple users", async () => {
|
||||
const user1 = await db.createUser({
|
||||
email: "goal1@example.com",
|
||||
firstName: "Goal",
|
||||
lastName: "User1",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
const user2 = await db.createUser({
|
||||
email: "goal2@example.com",
|
||||
firstName: "Goal",
|
||||
lastName: "User2",
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
|
||||
// Create fitness goals
|
||||
await db.createFitnessGoal({
|
||||
userId: user1.id,
|
||||
goalType: "weight_target",
|
||||
title: "Lose weight",
|
||||
targetValue: 10,
|
||||
currentValue: 0,
|
||||
unit: "kg",
|
||||
targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
status: "active",
|
||||
} as any);
|
||||
await db.createFitnessGoal({
|
||||
userId: user2.id,
|
||||
goalType: "strength_milestone",
|
||||
title: "Gain muscle",
|
||||
targetValue: 5,
|
||||
currentValue: 0,
|
||||
unit: "kg",
|
||||
targetDate: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000),
|
||||
status: "active",
|
||||
} as any);
|
||||
|
||||
const goals = await db.getFitnessGoalsByUserIds([user1.id, user2.id]);
|
||||
|
||||
expect(goals.length).toBeGreaterThanOrEqual(2);
|
||||
const user1Goals = goals.filter((g) => g.userId === user1.id);
|
||||
const user2Goals = goals.filter((g) => g.userId === user2.id);
|
||||
expect(user1Goals.length).toBeGreaterThanOrEqual(1);
|
||||
expect(user2Goals.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("should return empty array for empty input", async () => {
|
||||
const goals = await db.getFitnessGoalsByUserIds([]);
|
||||
expect(goals).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Batch Query Performance", () => {
|
||||
it("should be more efficient than individual queries", async () => {
|
||||
// Create 10 users with clients
|
||||
const users = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const user = await db.createUser({
|
||||
email: `perf${i}@example.com`,
|
||||
firstName: "Perf",
|
||||
lastName: `User${i}`,
|
||||
password: "password",
|
||||
role: "client",
|
||||
});
|
||||
await db.createClient({
|
||||
userId: user.id,
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
});
|
||||
users.push(user);
|
||||
}
|
||||
|
||||
// Initialize DrizzleDatabase with the in-memory db
|
||||
db = new DrizzleDatabase(config, drizzleDb as any)
|
||||
await db.connect()
|
||||
})
|
||||
const userIds = users.map((u) => u.id);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.disconnect()
|
||||
sqlite.close()
|
||||
})
|
||||
// Batch query (should be 1 query)
|
||||
const batchStart = Date.now();
|
||||
const batchClients = await db.getClientsByUserIds(userIds);
|
||||
const batchTime = Date.now() - batchStart;
|
||||
|
||||
describe('User Operations', () => {
|
||||
it('should create and retrieve a user', async () => {
|
||||
const newUser = {
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
password: 'password123',
|
||||
role: 'client' as const
|
||||
}
|
||||
// Individual queries (should be 10 queries)
|
||||
const individualStart = Date.now();
|
||||
const individualClients = [];
|
||||
for (const userId of userIds) {
|
||||
const client = await db.getClientByUserId(userId);
|
||||
if (client) individualClients.push(client);
|
||||
}
|
||||
const individualTime = Date.now() - individualStart;
|
||||
|
||||
const createdUser = await db.createUser(newUser)
|
||||
expect(createdUser).toBeDefined()
|
||||
expect(createdUser.id).toBeDefined()
|
||||
expect(createdUser.email).toBe(newUser.email)
|
||||
// Verify same results
|
||||
expect(batchClients.length).toBe(individualClients.length);
|
||||
expect(batchClients.length).toBe(10);
|
||||
|
||||
const fetchedUser = await db.getUserById(createdUser.id)
|
||||
expect(fetchedUser).toBeDefined()
|
||||
expect(fetchedUser?.id).toBe(createdUser.id)
|
||||
expect(fetchedUser?.email).toBe(createdUser.email)
|
||||
// SQLite/Drizzle might truncate ms or handle dates differently in test env
|
||||
expect(new Date(fetchedUser!.createdAt).getTime()).toBeLessThanOrEqual(createdUser.createdAt.getTime())
|
||||
expect(new Date(fetchedUser!.updatedAt).getTime()).toBeLessThanOrEqual(createdUser.updatedAt.getTime())
|
||||
})
|
||||
|
||||
it('should update a user', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'update@example.com',
|
||||
firstName: 'Update',
|
||||
lastName: 'Me',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const updated = await db.updateUser(user.id, { firstName: 'Updated' })
|
||||
expect(updated?.firstName).toBe('Updated')
|
||||
|
||||
const fetched = await db.getUserById(user.id)
|
||||
expect(fetched?.firstName).toBe('Updated')
|
||||
})
|
||||
|
||||
it('should delete a user', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'delete@example.com',
|
||||
firstName: 'Delete',
|
||||
lastName: 'Me',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const deleted = await db.deleteUser(user.id)
|
||||
expect(deleted).toBe(true)
|
||||
|
||||
const fetched = await db.getUserById(user.id)
|
||||
expect(fetched).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Client Operations', () => {
|
||||
it('should create and retrieve a client', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'client@example.com',
|
||||
firstName: 'Client',
|
||||
lastName: 'User',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const newClient = {
|
||||
userId: user.id,
|
||||
membershipType: 'basic' as const,
|
||||
membershipStatus: 'active' as const,
|
||||
joinDate: new Date()
|
||||
}
|
||||
|
||||
const createdClient = await db.createClient(newClient)
|
||||
expect(createdClient).toBeDefined()
|
||||
expect(createdClient.userId).toBe(user.id)
|
||||
|
||||
const fetchedClient = await db.getClientById(createdClient.id)
|
||||
expect(fetchedClient).toBeDefined()
|
||||
expect(fetchedClient?.id).toBe(createdClient.id)
|
||||
expect(fetchedClient?.userId).toBe(createdClient.userId)
|
||||
expect(fetchedClient?.membershipType).toBe(createdClient.membershipType)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fitness Profile Operations', () => {
|
||||
it('should create and retrieve a fitness profile', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'profile@example.com',
|
||||
firstName: 'Profile',
|
||||
lastName: 'User',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const newProfile = {
|
||||
userId: user.id,
|
||||
height: 180,
|
||||
weight: 75,
|
||||
age: 30,
|
||||
gender: 'male' as const,
|
||||
activityLevel: 'moderately_active' as const,
|
||||
fitnessGoals: ['weight_loss'],
|
||||
exerciseHabits: 'None',
|
||||
dietHabits: 'None',
|
||||
medicalConditions: 'None'
|
||||
}
|
||||
|
||||
const createdProfile = await db.createFitnessProfile(newProfile as any)
|
||||
expect(createdProfile).toBeDefined()
|
||||
expect(createdProfile.userId).toBe(user.id)
|
||||
|
||||
const fetchedProfile = await db.getFitnessProfileByUserId(user.id)
|
||||
expect(fetchedProfile).toBeDefined()
|
||||
expect(fetchedProfile?.userId).toBe(user.id)
|
||||
expect(fetchedProfile?.fitnessGoals).toEqual(expect.arrayContaining(['weight_loss']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Attendance Operations', () => {
|
||||
it('should check in and check out', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'attendance@example.com',
|
||||
firstName: 'Attendance',
|
||||
lastName: 'User',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const checkIn = await db.checkIn(user.id, 'gym')
|
||||
expect(checkIn).toBeDefined()
|
||||
expect(checkIn.checkOutTime).toBeUndefined()
|
||||
|
||||
const activeCheckIn = await db.getActiveCheckIn(user.id)
|
||||
expect(activeCheckIn).toBeDefined()
|
||||
expect(activeCheckIn?.id).toBe(checkIn.id)
|
||||
|
||||
const checkOut = await db.checkOut(checkIn.id)
|
||||
expect(checkOut?.checkOutTime).toBeDefined()
|
||||
|
||||
const activeCheckInAfter = await db.getActiveCheckIn(user.id)
|
||||
expect(activeCheckInAfter).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
// Batch should be faster (or at least not significantly slower)
|
||||
// In practice, batch is much faster, but in-memory DB might not show huge difference
|
||||
expect(batchTime).toBeLessThanOrEqual(individualTime * 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,13 @@ import {
|
||||
desc,
|
||||
asc,
|
||||
sql,
|
||||
inArray,
|
||||
gte,
|
||||
lte,
|
||||
or,
|
||||
like,
|
||||
} from "@fitai/database";
|
||||
import { SQL, or, like, inArray, gte, lte } from "drizzle-orm";
|
||||
import { SQL } from "drizzle-orm";
|
||||
import type { SortConfig, FilterCondition } from "../filtering";
|
||||
import { buildWhereConditions, buildSearchCondition } from "../filtering";
|
||||
import log from "../logger";
|
||||
@@ -643,6 +648,17 @@ export class DrizzleDatabase implements IDatabase {
|
||||
return result ? this.mapClient(result) : null;
|
||||
}
|
||||
|
||||
async getClientsByUserIds(userIds: string[]): Promise<Client[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(inArray(clients.userId, userIds))
|
||||
.all();
|
||||
return results.map((r) => this.mapClient(r));
|
||||
}
|
||||
|
||||
async getAllClients(): Promise<Client[]> {
|
||||
const results = await this.db
|
||||
.select()
|
||||
@@ -1011,6 +1027,36 @@ export class DrizzleDatabase implements IDatabase {
|
||||
return history.find((a) => !a.checkOutTime) || null;
|
||||
}
|
||||
|
||||
async getAttendanceHistoriesByUserIds(
|
||||
userIds: string[],
|
||||
): Promise<Attendance[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(attendance)
|
||||
.where(inArray(attendance.userId, userIds))
|
||||
.orderBy(desc(attendance.checkInTime))
|
||||
.all();
|
||||
return results.map((r) => this.mapAttendance(r));
|
||||
}
|
||||
|
||||
async getActiveCheckInsByUserIds(userIds: string[]): Promise<Attendance[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
// Get all attendance records for these users and filter for active check-ins
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(attendance)
|
||||
.where(inArray(attendance.userId, userIds))
|
||||
.all();
|
||||
|
||||
// Filter for active check-ins (no checkout time)
|
||||
return results
|
||||
.filter((r) => !r.checkOutTime)
|
||||
.map((r) => this.mapAttendance(r));
|
||||
}
|
||||
|
||||
// Recommendation operations
|
||||
async createRecommendation(
|
||||
data: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">,
|
||||
@@ -1079,6 +1125,20 @@ export class DrizzleDatabase implements IDatabase {
|
||||
return result ? this.mapRecommendation(result) : null;
|
||||
}
|
||||
|
||||
async getRecommendationsByUserIds(
|
||||
userIds: string[],
|
||||
): Promise<Recommendation[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(recommendations)
|
||||
.where(inArray(recommendations.userId, userIds))
|
||||
.orderBy(desc(recommendations.createdAt))
|
||||
.all();
|
||||
return results.map((r) => this.mapRecommendation(r));
|
||||
}
|
||||
|
||||
// Fitness Goals operations
|
||||
async createFitnessGoal(
|
||||
goalData: Omit<FitnessGoal, "createdAt" | "updatedAt">,
|
||||
@@ -1190,6 +1250,18 @@ export class DrizzleDatabase implements IDatabase {
|
||||
return this.getFitnessGoalById(id);
|
||||
}
|
||||
|
||||
async getFitnessGoalsByUserIds(userIds: string[]): Promise<FitnessGoal[]> {
|
||||
if (userIds.length === 0) return [];
|
||||
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(fitnessGoals)
|
||||
.where(inArray(fitnessGoals.userId, userIds))
|
||||
.orderBy(desc(fitnessGoals.createdAt))
|
||||
.all();
|
||||
return results.map((r) => this.mapFitnessGoal(r));
|
||||
}
|
||||
|
||||
async getDashboardStats(): Promise<{
|
||||
totalUsers: number;
|
||||
activeClients: number;
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface IDatabase {
|
||||
createClient(client: Omit<Client, "id">): Promise<Client>;
|
||||
getClientById(id: string): Promise<Client | null>;
|
||||
getClientByUserId(userId: string): Promise<Client | null>;
|
||||
getClientsByUserIds(userIds: string[]): Promise<Client[]>;
|
||||
getAllClients(): Promise<Client[]>;
|
||||
getClientsWithPagination(params: {
|
||||
page: number;
|
||||
@@ -113,6 +114,8 @@ export interface IDatabase {
|
||||
): Promise<Attendance>;
|
||||
checkOut(attendanceId: string): Promise<Attendance | null>;
|
||||
getAttendanceHistory(userId: string): Promise<Attendance[]>;
|
||||
getAttendanceHistoriesByUserIds(userIds: string[]): Promise<Attendance[]>;
|
||||
getActiveCheckInsByUserIds(userIds: string[]): Promise<Attendance[]>;
|
||||
getAllAttendance(): Promise<Attendance[]>;
|
||||
getAttendanceWithPagination(params: {
|
||||
page: number;
|
||||
@@ -131,6 +134,7 @@ export interface IDatabase {
|
||||
>,
|
||||
): Promise<Recommendation>;
|
||||
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
|
||||
getRecommendationsByUserIds(userIds: string[]): Promise<Recommendation[]>;
|
||||
getAllRecommendations(): Promise<Recommendation[]>;
|
||||
updateRecommendation(
|
||||
id: string,
|
||||
@@ -147,6 +151,7 @@ export interface IDatabase {
|
||||
userId: string,
|
||||
status?: string,
|
||||
): Promise<FitnessGoal[]>;
|
||||
getFitnessGoalsByUserIds(userIds: string[]): Promise<FitnessGoal[]>;
|
||||
updateFitnessGoal(
|
||||
id: string,
|
||||
updates: Partial<FitnessGoal>,
|
||||
|
||||
@@ -7,7 +7,7 @@ import pino from "pino";
|
||||
|
||||
/**
|
||||
* Create logger instance based on environment
|
||||
* - Development: Pretty-printed logs to console
|
||||
* - Development: JSON logs to console (pino-pretty disabled due to Turbopack compatibility)
|
||||
* - Production: JSON logs for aggregation/monitoring
|
||||
*/
|
||||
const logger = pino({
|
||||
@@ -19,16 +19,9 @@ const logger = pino({
|
||||
return { level: label };
|
||||
},
|
||||
},
|
||||
...(process.env.NODE_ENV !== "production" && {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "HH:MM:ss Z",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Note: pino-pretty transport disabled for Next.js 15+ Turbopack compatibility
|
||||
// The transport uses worker threads which cause module resolution issues
|
||||
// Logs will be in JSON format in all environments
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Performance Benchmark Tests
|
||||
*
|
||||
* These tests measure and verify performance improvements from Phase 9:
|
||||
* - Cache hit/miss performance
|
||||
* - Monitoring overhead measurement
|
||||
* - Performance utilities efficiency
|
||||
*
|
||||
* @jest-environment node
|
||||
*/
|
||||
|
||||
import {
|
||||
measure,
|
||||
measureAsync,
|
||||
getOperationStats,
|
||||
clearMetrics,
|
||||
startTimer,
|
||||
} from "../monitoring";
|
||||
import { Cache, clearAllCaches, getAllCacheStats } from "../cache";
|
||||
|
||||
describe("Performance Benchmarks", () => {
|
||||
beforeEach(() => {
|
||||
clearMetrics();
|
||||
clearAllCaches();
|
||||
});
|
||||
|
||||
describe("Cache Performance", () => {
|
||||
it("should dramatically improve repeated access performance", async () => {
|
||||
const cache = new Cache<any>(60000); // 60 second TTL
|
||||
|
||||
// Expensive operation simulation
|
||||
const expensiveOperation = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10)); // 10ms delay
|
||||
return { data: "expensive result" };
|
||||
};
|
||||
|
||||
// First access (cache miss) - should take ~10ms
|
||||
const timer1 = startTimer("cache-miss");
|
||||
await cache.getOrSet("key", expensiveOperation);
|
||||
const missTime = timer1.end();
|
||||
|
||||
// Second access (cache hit) - should be <1ms
|
||||
const timer2 = startTimer("cache-hit");
|
||||
await cache.getOrSet("key", expensiveOperation);
|
||||
const hitTime = timer2.end();
|
||||
|
||||
console.log(`Cache miss: ${missTime.toFixed(2)}ms`);
|
||||
console.log(`Cache hit: ${hitTime.toFixed(2)}ms`);
|
||||
console.log(`Speedup: ${(missTime / hitTime).toFixed(2)}x`);
|
||||
|
||||
// Cache hit should be at least 5x faster
|
||||
expect(missTime / hitTime).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("should handle high-frequency access efficiently", () => {
|
||||
const cache = new Cache<number>(60000);
|
||||
const iterations = 1000;
|
||||
|
||||
// Populate cache
|
||||
for (let i = 0; i < 100; i++) {
|
||||
cache.set(`key_${i}`, i);
|
||||
}
|
||||
|
||||
// Measure 1000 cache hits
|
||||
const startTime = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
cache.get(`key_${i % 100}`);
|
||||
}
|
||||
const endTime = performance.now();
|
||||
const totalTime = endTime - startTime;
|
||||
const avgTime = totalTime / iterations;
|
||||
|
||||
console.log(`1000 cache hits: ${totalTime.toFixed(2)}ms`);
|
||||
console.log(`Average per hit: ${avgTime.toFixed(4)}ms`);
|
||||
|
||||
// Average cache hit should be < 0.1ms
|
||||
expect(avgTime).toBeLessThan(0.1);
|
||||
});
|
||||
|
||||
it("should provide accurate hit/miss statistics", () => {
|
||||
const cache = new Cache<string>(60000);
|
||||
|
||||
// Set some values
|
||||
cache.set("key1", "value1");
|
||||
cache.set("key2", "value2");
|
||||
cache.set("key3", "value3");
|
||||
|
||||
// Perform hits and misses
|
||||
cache.get("key1"); // hit
|
||||
cache.get("key2"); // hit
|
||||
cache.get("key4"); // miss
|
||||
cache.get("key1"); // hit
|
||||
cache.get("key5"); // miss
|
||||
|
||||
const stats = cache.getStats();
|
||||
expect(stats.hits).toBe(3);
|
||||
expect(stats.misses).toBe(2);
|
||||
expect(stats.hitRate).toBeCloseTo(0.6, 2);
|
||||
});
|
||||
|
||||
it("should handle cache eviction efficiently", () => {
|
||||
const cache = new Cache<string>(100); // 100ms TTL
|
||||
const iterations = 100;
|
||||
|
||||
cache.set("key1", "value1");
|
||||
|
||||
// Wait for expiration
|
||||
const start = performance.now();
|
||||
const waitTime = 150; // Wait longer than TTL
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
// Value should be expired
|
||||
const value = cache.get("key1");
|
||||
expect(value).toBeNull();
|
||||
|
||||
// Cleanup should remove it
|
||||
const removed = cache.cleanup();
|
||||
expect(removed).toBe(1);
|
||||
|
||||
console.log(`Eviction test took ${elapsed.toFixed(2)}ms`);
|
||||
resolve();
|
||||
}, waitTime);
|
||||
});
|
||||
}, 10000);
|
||||
|
||||
it("should scale well with many entries", () => {
|
||||
const cache = new Cache<number>(60000);
|
||||
const entryCount = 10000;
|
||||
|
||||
// Insert many entries
|
||||
const insertStart = performance.now();
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
cache.set(`key_${i}`, i);
|
||||
}
|
||||
const insertTime = performance.now() - insertStart;
|
||||
|
||||
// Retrieve many entries
|
||||
const retrieveStart = performance.now();
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
cache.get(`key_${i}`);
|
||||
}
|
||||
const retrieveTime = performance.now() - retrieveStart;
|
||||
|
||||
console.log(
|
||||
`Inserted ${entryCount} entries in ${insertTime.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`Retrieved ${entryCount} entries in ${retrieveTime.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(`Average insert: ${(insertTime / entryCount).toFixed(4)}ms`);
|
||||
console.log(
|
||||
`Average retrieve: ${(retrieveTime / entryCount).toFixed(4)}ms`,
|
||||
);
|
||||
|
||||
// Should handle 10k entries efficiently
|
||||
expect(insertTime / entryCount).toBeLessThan(0.1);
|
||||
expect(retrieveTime / entryCount).toBeLessThan(0.05);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Monitoring Overhead", () => {
|
||||
it("should add minimal overhead to fast operations", async () => {
|
||||
const iterations = 1000;
|
||||
|
||||
// Baseline: operation without monitoring
|
||||
const startBaseline = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const x = Math.sqrt(i);
|
||||
}
|
||||
const baselineTime = performance.now() - startBaseline;
|
||||
|
||||
// With monitoring
|
||||
const startMonitored = performance.now();
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
measure("sqrt", () => Math.sqrt(i));
|
||||
}
|
||||
const monitoredTime = performance.now() - startMonitored;
|
||||
|
||||
const overhead = monitoredTime - baselineTime;
|
||||
const overheadPercent = (overhead / baselineTime) * 100;
|
||||
|
||||
console.log(`Baseline: ${baselineTime.toFixed(2)}ms`);
|
||||
console.log(`Monitored: ${monitoredTime.toFixed(2)}ms`);
|
||||
console.log(
|
||||
`Overhead: ${overhead.toFixed(2)}ms (${overheadPercent.toFixed(1)}%)`,
|
||||
);
|
||||
|
||||
// Monitoring overhead should be < 100% for fast operations
|
||||
expect(overheadPercent).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("should efficiently track many operations", async () => {
|
||||
const operationCount = 1000;
|
||||
|
||||
const start = performance.now();
|
||||
for (let i = 0; i < operationCount; i++) {
|
||||
await measureAsync(`operation-${i % 10}`, async () => {
|
||||
// Simulate lightweight async work
|
||||
return Promise.resolve(i);
|
||||
});
|
||||
}
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
console.log(
|
||||
`Tracked ${operationCount} operations in ${elapsed.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`Average per operation: ${(elapsed / operationCount).toFixed(4)}ms`,
|
||||
);
|
||||
|
||||
// Should track efficiently
|
||||
expect(elapsed / operationCount).toBeLessThan(1); // < 1ms per operation
|
||||
});
|
||||
|
||||
it("should provide accurate statistics for multiple operations", () => {
|
||||
// Perform operations with known durations
|
||||
for (let i = 0; i < 10; i++) {
|
||||
measure("fast-op", () => Math.random());
|
||||
}
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
measure("medium-op", () => {
|
||||
const arr = Array.from({ length: 1000 }, () => Math.random());
|
||||
return arr.reduce((a, b) => a + b, 0);
|
||||
});
|
||||
}
|
||||
|
||||
const fastStats = getOperationStats("fast-op");
|
||||
const mediumStats = getOperationStats("medium-op");
|
||||
|
||||
expect(fastStats?.count).toBe(10);
|
||||
expect(mediumStats?.count).toBe(5);
|
||||
|
||||
// Medium operations should be slower than fast operations
|
||||
if (fastStats && mediumStats) {
|
||||
console.log(`Fast op avg: ${fastStats.avgDuration.toFixed(4)}ms`);
|
||||
console.log(`Medium op avg: ${mediumStats.avgDuration.toFixed(4)}ms`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combined Optimization Benefits", () => {
|
||||
it("should demonstrate cache + monitoring working together", async () => {
|
||||
const cache = new Cache<number>(60000);
|
||||
|
||||
// Expensive async function
|
||||
const expensiveCalc = async (n: number): Promise<number> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return n * n;
|
||||
};
|
||||
|
||||
// Without cache (slow)
|
||||
const timer1 = startTimer("uncached");
|
||||
const result1 = await expensiveCalc(100);
|
||||
const uncachedTime = timer1.end();
|
||||
|
||||
// With cache (first call - miss)
|
||||
const timer2 = startTimer("cached-miss");
|
||||
const result2 = await cache.getOrSet("calc-100", () =>
|
||||
expensiveCalc(100),
|
||||
);
|
||||
const cachedMissTime = timer2.end();
|
||||
|
||||
// With cache (second call - hit)
|
||||
const timer3 = startTimer("cached-hit");
|
||||
const result3 = await cache.getOrSet("calc-100", () =>
|
||||
expensiveCalc(100),
|
||||
);
|
||||
const cachedHitTime = timer3.end();
|
||||
|
||||
console.log(`Uncached: ${uncachedTime.toFixed(2)}ms`);
|
||||
console.log(`Cached (miss): ${cachedMissTime.toFixed(2)}ms`);
|
||||
console.log(`Cached (hit): ${cachedHitTime.toFixed(2)}ms`);
|
||||
console.log(
|
||||
`Speedup from caching: ${(uncachedTime / cachedHitTime).toFixed(2)}x`,
|
||||
);
|
||||
|
||||
// Results should be the same
|
||||
expect(result1).toBe(result2);
|
||||
expect(result2).toBe(result3);
|
||||
|
||||
// Cache hit should be dramatically faster
|
||||
expect(uncachedTime / cachedHitTime).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// Verify monitoring tracked all operations
|
||||
const uncachedStats = getOperationStats("uncached");
|
||||
const cachedHitStats = getOperationStats("cached-hit");
|
||||
|
||||
expect(uncachedStats).toBeDefined();
|
||||
expect(cachedHitStats).toBeDefined();
|
||||
});
|
||||
|
||||
it("should maintain performance under concurrent access", async () => {
|
||||
const cache = new Cache<number>(60000);
|
||||
const concurrentRequests = 100;
|
||||
|
||||
const slowOperation = async (id: number): Promise<number> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return id * 2;
|
||||
};
|
||||
|
||||
// Simulate concurrent cache access
|
||||
const start = performance.now();
|
||||
const promises = Array.from({ length: concurrentRequests }, (_, i) =>
|
||||
measureAsync(`concurrent-${i}`, async () => {
|
||||
return cache.getOrSet(`key-${i % 10}`, () => slowOperation(i));
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
console.log(
|
||||
`${concurrentRequests} concurrent requests in ${elapsed.toFixed(2)}ms`,
|
||||
);
|
||||
console.log(
|
||||
`Average per request: ${(elapsed / concurrentRequests).toFixed(2)}ms`,
|
||||
);
|
||||
|
||||
// With cache, should complete much faster than if all were uncached
|
||||
// Uncached would be: concurrentRequests * 5ms = 500ms
|
||||
// With cache (10 unique keys), should be much less
|
||||
expect(elapsed).toBeLessThan(200); // Should benefit from caching
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cache Statistics", () => {
|
||||
it("should track stats for multiple independent caches", () => {
|
||||
const cache1 = new Cache<string>(60000);
|
||||
const cache2 = new Cache<number>(60000);
|
||||
|
||||
// Populate caches
|
||||
cache1.set("key1", "value1");
|
||||
cache1.set("key2", "value2");
|
||||
cache2.set("key1", 100);
|
||||
cache2.set("key2", 200);
|
||||
cache2.set("key3", 300);
|
||||
|
||||
// Get individual stats
|
||||
const stats1 = cache1.getStats();
|
||||
const stats2 = cache2.getStats();
|
||||
|
||||
expect(stats1.size).toBe(2);
|
||||
expect(stats2.size).toBe(3);
|
||||
|
||||
// Perform reads to test hit rate
|
||||
cache1.get("key1"); // hit
|
||||
cache1.get("key3"); // miss
|
||||
cache2.get("key1"); // hit
|
||||
cache2.get("key2"); // hit
|
||||
|
||||
const updatedStats1 = cache1.getStats();
|
||||
const updatedStats2 = cache2.getStats();
|
||||
|
||||
expect(updatedStats1.hits).toBe(1);
|
||||
expect(updatedStats1.misses).toBe(1);
|
||||
expect(updatedStats2.hits).toBe(2);
|
||||
expect(updatedStats2.misses).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { Cache, clearAllCaches, getAllCacheStats } from "../cache";
|
||||
|
||||
describe("Cache", () => {
|
||||
let cache: Cache<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
cache = new Cache<string>(1000); // 1 second TTL
|
||||
});
|
||||
|
||||
describe("set and get", () => {
|
||||
it("should store and retrieve a value", () => {
|
||||
cache.set("key1", "value1");
|
||||
const value = cache.get("key1");
|
||||
|
||||
expect(value).toBe("value1");
|
||||
});
|
||||
|
||||
it("should return null for non-existent key", () => {
|
||||
const value = cache.get("nonexistent");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for expired key", async () => {
|
||||
cache.set("key1", "value1", 50); // 50ms TTL
|
||||
|
||||
// Wait for expiration
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const value = cache.get("key1");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it("should use default TTL when not specified", () => {
|
||||
const shortCache = new Cache<string>(100); // 100ms default
|
||||
shortCache.set("key1", "value1");
|
||||
|
||||
const value = shortCache.get("key1");
|
||||
expect(value).toBe("value1");
|
||||
});
|
||||
|
||||
it("should override default TTL when specified", async () => {
|
||||
const longCache = new Cache<string>(5000); // 5s default
|
||||
longCache.set("key1", "value1", 50); // 50ms override
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const value = longCache.get("key1");
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrSet", () => {
|
||||
it("should return cached value if exists", async () => {
|
||||
let callCount = 0;
|
||||
const fn = async () => {
|
||||
callCount++;
|
||||
return "computed-value";
|
||||
};
|
||||
|
||||
const value1 = await cache.getOrSet("key1", fn);
|
||||
const value2 = await cache.getOrSet("key1", fn);
|
||||
|
||||
expect(value1).toBe("computed-value");
|
||||
expect(value2).toBe("computed-value");
|
||||
expect(callCount).toBe(1); // Function should only be called once
|
||||
});
|
||||
|
||||
it("should compute value if not cached", async () => {
|
||||
const value = await cache.getOrSet("key1", async () => {
|
||||
return "new-value";
|
||||
});
|
||||
|
||||
expect(value).toBe("new-value");
|
||||
});
|
||||
|
||||
it("should recompute value after expiration", async () => {
|
||||
let callCount = 0;
|
||||
const fn = async () => {
|
||||
callCount++;
|
||||
return `value-${callCount}`;
|
||||
};
|
||||
|
||||
const value1 = await cache.getOrSet("key1", fn, 50); // 50ms TTL
|
||||
expect(value1).toBe("value-1");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const value2 = await cache.getOrSet("key1", fn, 50);
|
||||
expect(value2).toBe("value-2");
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should remove a key from cache", () => {
|
||||
cache.set("key1", "value1");
|
||||
expect(cache.get("key1")).toBe("value1");
|
||||
|
||||
const deleted = cache.delete("key1");
|
||||
expect(deleted).toBe(true);
|
||||
expect(cache.get("key1")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return false when deleting non-existent key", () => {
|
||||
const deleted = cache.delete("nonexistent");
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("should remove all entries", () => {
|
||||
cache.set("key1", "value1");
|
||||
cache.set("key2", "value2");
|
||||
cache.set("key3", "value3");
|
||||
|
||||
expect(cache.size()).toBe(3);
|
||||
|
||||
cache.clear();
|
||||
|
||||
expect(cache.size()).toBe(0);
|
||||
expect(cache.get("key1")).toBeNull();
|
||||
expect(cache.get("key2")).toBeNull();
|
||||
expect(cache.get("key3")).toBeNull();
|
||||
});
|
||||
|
||||
it("should reset hit/miss statistics", () => {
|
||||
cache.set("key1", "value1");
|
||||
cache.get("key1"); // hit
|
||||
cache.get("nonexistent"); // miss
|
||||
|
||||
let stats = cache.getStats();
|
||||
expect(stats.hits).toBe(1);
|
||||
expect(stats.misses).toBe(1);
|
||||
|
||||
cache.clear();
|
||||
|
||||
stats = cache.getStats();
|
||||
expect(stats.hits).toBe(0);
|
||||
expect(stats.misses).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStats", () => {
|
||||
it("should track hits and misses", () => {
|
||||
cache.set("key1", "value1");
|
||||
|
||||
cache.get("key1"); // hit
|
||||
cache.get("key1"); // hit
|
||||
cache.get("nonexistent"); // miss
|
||||
|
||||
const stats = cache.getStats();
|
||||
|
||||
expect(stats.hits).toBe(2);
|
||||
expect(stats.misses).toBe(1);
|
||||
expect(stats.hitRate).toBeCloseTo(2 / 3, 2);
|
||||
expect(stats.size).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 0 hit rate when no requests", () => {
|
||||
const stats = cache.getStats();
|
||||
expect(stats.hitRate).toBe(0);
|
||||
expect(stats.hits).toBe(0);
|
||||
expect(stats.misses).toBe(0);
|
||||
});
|
||||
|
||||
it("should count expired entries as misses", async () => {
|
||||
cache.set("key1", "value1", 50);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
cache.get("key1"); // miss (expired)
|
||||
|
||||
const stats = cache.getStats();
|
||||
expect(stats.misses).toBe(1);
|
||||
expect(stats.hits).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("should remove expired entries", async () => {
|
||||
cache.set("key1", "value1", 50); // 50ms TTL
|
||||
cache.set("key2", "value2", 1000); // 1s TTL
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const removed = cache.cleanup();
|
||||
|
||||
expect(removed).toBe(1);
|
||||
expect(cache.get("key1")).toBeNull();
|
||||
expect(cache.get("key2")).toBe("value2");
|
||||
});
|
||||
|
||||
it("should return 0 when no expired entries", () => {
|
||||
cache.set("key1", "value1", 1000);
|
||||
const removed = cache.cleanup();
|
||||
|
||||
expect(removed).toBe(0);
|
||||
expect(cache.size()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("has", () => {
|
||||
it("should return true for existing non-expired key", () => {
|
||||
cache.set("key1", "value1");
|
||||
expect(cache.has("key1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-existent key", () => {
|
||||
expect(cache.has("nonexistent")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for expired key", async () => {
|
||||
cache.set("key1", "value1", 50);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(cache.has("key1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("keys", () => {
|
||||
it("should return all cache keys", () => {
|
||||
cache.set("key1", "value1");
|
||||
cache.set("key2", "value2");
|
||||
cache.set("key3", "value3");
|
||||
|
||||
const keys = cache.keys();
|
||||
|
||||
expect(keys).toHaveLength(3);
|
||||
expect(keys).toContain("key1");
|
||||
expect(keys).toContain("key2");
|
||||
expect(keys).toContain("key3");
|
||||
});
|
||||
|
||||
it("should return empty array for empty cache", () => {
|
||||
const keys = cache.keys();
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("size", () => {
|
||||
it("should return number of entries", () => {
|
||||
expect(cache.size()).toBe(0);
|
||||
|
||||
cache.set("key1", "value1");
|
||||
expect(cache.size()).toBe(1);
|
||||
|
||||
cache.set("key2", "value2");
|
||||
expect(cache.size()).toBe(2);
|
||||
|
||||
cache.delete("key1");
|
||||
expect(cache.size()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Complex data types", () => {
|
||||
it("should cache objects", () => {
|
||||
const objCache = new Cache<{ name: string; age: number }>();
|
||||
const obj = { name: "John", age: 30 };
|
||||
|
||||
objCache.set("user1", obj);
|
||||
const cached = objCache.get("user1");
|
||||
|
||||
expect(cached).toEqual(obj);
|
||||
});
|
||||
|
||||
it("should cache arrays", () => {
|
||||
const arrayCache = new Cache<number[]>();
|
||||
const arr = [1, 2, 3, 4, 5];
|
||||
|
||||
arrayCache.set("numbers", arr);
|
||||
const cached = arrayCache.get("numbers");
|
||||
|
||||
expect(cached).toEqual(arr);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Global cache utilities", () => {
|
||||
it("clearAllCaches should clear all global caches", () => {
|
||||
clearAllCaches();
|
||||
// Should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("getAllCacheStats should return stats for all global caches", () => {
|
||||
const stats = getAllCacheStats();
|
||||
|
||||
expect(stats).toHaveProperty("users");
|
||||
expect(stats).toHaveProperty("fitnessGoals");
|
||||
expect(stats).toHaveProperty("recommendations");
|
||||
expect(stats).toHaveProperty("dashboardStats");
|
||||
|
||||
expect(stats.users).toHaveProperty("hits");
|
||||
expect(stats.users).toHaveProperty("misses");
|
||||
expect(stats.users).toHaveProperty("size");
|
||||
expect(stats.users).toHaveProperty("hitRate");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import {
|
||||
startTimer,
|
||||
measure,
|
||||
measureAsync,
|
||||
getOperationStats,
|
||||
getAllStats,
|
||||
getRecentMetrics,
|
||||
clearMetrics,
|
||||
} from "../monitoring";
|
||||
|
||||
describe("Performance Monitoring", () => {
|
||||
beforeEach(() => {
|
||||
// Clear metrics before each test
|
||||
clearMetrics();
|
||||
});
|
||||
|
||||
describe("startTimer", () => {
|
||||
it("should create a timer and measure duration", async () => {
|
||||
const timer = startTimer("test-operation");
|
||||
|
||||
// Simulate some work
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const duration = timer.end();
|
||||
|
||||
expect(duration).toBeGreaterThanOrEqual(10);
|
||||
expect(duration).toBeLessThan(50); // Allow some overhead
|
||||
});
|
||||
|
||||
it("should record metric with metadata", () => {
|
||||
const timer = startTimer("test-operation");
|
||||
timer.end({ count: 100, success: true });
|
||||
|
||||
const stats = getOperationStats("test-operation");
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats?.count).toBe(1);
|
||||
|
||||
const metrics = getRecentMetrics(1);
|
||||
expect(metrics).toHaveLength(1);
|
||||
expect(metrics[0].metadata).toEqual({ count: 100, success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("measure", () => {
|
||||
it("should measure synchronous function execution", () => {
|
||||
let result = 0;
|
||||
const returnValue = measure("sync-operation", () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
result += i;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
expect(returnValue).toBe(result);
|
||||
|
||||
const stats = getOperationStats("sync-operation");
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats?.count).toBe(1);
|
||||
expect(stats?.avgDuration).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("measureAsync", () => {
|
||||
it("should measure async function execution", async () => {
|
||||
const result = await measureAsync("async-operation", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return "test-result";
|
||||
});
|
||||
|
||||
expect(result).toBe("test-result");
|
||||
|
||||
const stats = getOperationStats("async-operation");
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats?.count).toBe(1);
|
||||
expect(stats?.avgDuration).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("should record error in metadata when function throws", async () => {
|
||||
await expect(
|
||||
measureAsync("failing-operation", async () => {
|
||||
throw new Error("Test error");
|
||||
}),
|
||||
).rejects.toThrow("Test error");
|
||||
|
||||
const metrics = getRecentMetrics(1);
|
||||
expect(metrics).toHaveLength(1);
|
||||
expect(metrics[0].metadata).toEqual({ error: true });
|
||||
});
|
||||
|
||||
it("should support custom metadata", async () => {
|
||||
await measureAsync(
|
||||
"async-operation-with-metadata",
|
||||
async () => {
|
||||
return "result";
|
||||
},
|
||||
{ userId: "123", action: "fetch" },
|
||||
);
|
||||
|
||||
const metrics = getRecentMetrics(1);
|
||||
expect(metrics[0].metadata).toEqual({
|
||||
userId: "123",
|
||||
action: "fetch",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOperationStats", () => {
|
||||
it("should return null for unknown operation", () => {
|
||||
const stats = getOperationStats("nonexistent-operation");
|
||||
expect(stats).toBeNull();
|
||||
});
|
||||
|
||||
it("should calculate correct statistics for multiple operations", async () => {
|
||||
// Create operations with different durations
|
||||
await measureAsync("multi-operation", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
await measureAsync("multi-operation", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
await measureAsync("multi-operation", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
});
|
||||
|
||||
const stats = getOperationStats("multi-operation");
|
||||
expect(stats).not.toBeNull();
|
||||
expect(stats?.count).toBe(3);
|
||||
expect(stats?.minDuration).toBeGreaterThanOrEqual(10);
|
||||
expect(stats?.maxDuration).toBeGreaterThanOrEqual(20);
|
||||
expect(stats?.avgDuration).toBeGreaterThanOrEqual(10);
|
||||
expect(stats?.avgDuration).toBeLessThanOrEqual(30);
|
||||
expect(stats?.totalDuration).toBe(stats!.avgDuration * 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllStats", () => {
|
||||
it("should return empty object when no operations recorded", () => {
|
||||
const stats = getAllStats();
|
||||
expect(stats).toEqual({});
|
||||
});
|
||||
|
||||
it("should return stats for all operations", async () => {
|
||||
await measureAsync("operation-1", async () => {});
|
||||
await measureAsync("operation-2", async () => {});
|
||||
await measureAsync("operation-1", async () => {});
|
||||
|
||||
const stats = getAllStats();
|
||||
expect(Object.keys(stats)).toHaveLength(2);
|
||||
expect(stats["operation-1"]).toBeDefined();
|
||||
expect(stats["operation-2"]).toBeDefined();
|
||||
expect(stats["operation-1"].count).toBe(2);
|
||||
expect(stats["operation-2"].count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRecentMetrics", () => {
|
||||
it("should return empty array when no metrics", () => {
|
||||
const metrics = getRecentMetrics();
|
||||
expect(metrics).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return limited number of metrics", async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await measureAsync(`operation-${i}`, async () => {});
|
||||
}
|
||||
|
||||
const metrics = getRecentMetrics(5);
|
||||
expect(metrics).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should return all metrics when no limit specified", async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await measureAsync(`operation-${i}`, async () => {});
|
||||
}
|
||||
|
||||
const metrics = getRecentMetrics();
|
||||
expect(metrics).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearMetrics", () => {
|
||||
it("should clear all recorded metrics", async () => {
|
||||
await measureAsync("operation-1", async () => {});
|
||||
await measureAsync("operation-2", async () => {});
|
||||
|
||||
let metrics = getRecentMetrics();
|
||||
expect(metrics.length).toBeGreaterThan(0);
|
||||
|
||||
clearMetrics();
|
||||
|
||||
metrics = getRecentMetrics();
|
||||
expect(metrics).toEqual([]);
|
||||
|
||||
const stats = getAllStats();
|
||||
expect(stats).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Metrics History", () => {
|
||||
it("should maintain last 1000 metrics", async () => {
|
||||
// Create 1100 metrics
|
||||
for (let i = 0; i < 1100; i++) {
|
||||
const timer = startTimer("history-test");
|
||||
timer.end();
|
||||
}
|
||||
|
||||
const metrics = getRecentMetrics();
|
||||
// Should keep only 1000
|
||||
expect(metrics.length).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import { getDatabase } from "../database";
|
||||
|
||||
/**
|
||||
* Query Batching Utilities
|
||||
*
|
||||
* Prevents N+1 query problems by batching multiple queries into single operations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Batch load clients for multiple users
|
||||
*
|
||||
* @param userIds - Array of user IDs to fetch clients for
|
||||
* @returns Map of userId -> client data
|
||||
*
|
||||
* @example
|
||||
* const clientMap = await batchLoadClients(['user1', 'user2', 'user3']);
|
||||
* const user1Client = clientMap.get('user1');
|
||||
*/
|
||||
export async function batchLoadClients(
|
||||
userIds: string[],
|
||||
): Promise<Map<string, any>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const db = await getDatabase();
|
||||
const clients = (await db.getClientsByUserIds?.(userIds)) ?? [];
|
||||
|
||||
// If batch method doesn't exist, fall back to individual queries
|
||||
if (!db.getClientsByUserIds) {
|
||||
const results = await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
const client = await db.getClientByUserId(userId);
|
||||
return { userId, client };
|
||||
}),
|
||||
);
|
||||
return new Map(results.map((r) => [r.userId, r.client]));
|
||||
}
|
||||
|
||||
return new Map(clients.map((client: any) => [client.userId, client]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load active check-ins for multiple users
|
||||
*
|
||||
* @param userIds - Array of user IDs
|
||||
* @returns Map of userId -> active check-in data
|
||||
*/
|
||||
export async function batchLoadActiveCheckIns(
|
||||
userIds: string[],
|
||||
): Promise<Map<string, any>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const db = await getDatabase();
|
||||
const checkIns = (await db.getActiveCheckInsByUserIds?.(userIds)) ?? [];
|
||||
|
||||
// If batch method doesn't exist, fall back to individual queries
|
||||
if (!db.getActiveCheckInsByUserIds) {
|
||||
const results = await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
const checkIn = await db.getActiveCheckIn(userId);
|
||||
return { userId, checkIn };
|
||||
}),
|
||||
);
|
||||
return new Map(results.map((r) => [r.userId, r.checkIn]));
|
||||
}
|
||||
|
||||
return new Map(checkIns.map((checkIn: any) => [checkIn.userId, checkIn]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load attendance history for multiple users
|
||||
*
|
||||
* @param userIds - Array of user IDs
|
||||
* @returns Map of userId -> attendance history array
|
||||
*/
|
||||
export async function batchLoadAttendanceHistory(
|
||||
userIds: string[],
|
||||
): Promise<Map<string, any[]>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
// If batch method doesn't exist, fall back to individual queries
|
||||
if (!db.getAttendanceHistoriesByUserIds) {
|
||||
const results = await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
const history = await db.getAttendanceHistory(userId);
|
||||
return { userId, history };
|
||||
}),
|
||||
);
|
||||
return new Map(results.map((r) => [r.userId, r.history]));
|
||||
}
|
||||
|
||||
const histories = await db.getAttendanceHistoriesByUserIds(userIds);
|
||||
|
||||
// Group by userId
|
||||
const historyMap = new Map<string, any[]>();
|
||||
for (const record of histories) {
|
||||
const userId = record.userId;
|
||||
if (!historyMap.has(userId)) {
|
||||
historyMap.set(userId, []);
|
||||
}
|
||||
historyMap.get(userId)!.push(record);
|
||||
}
|
||||
|
||||
return historyMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load fitness goals for multiple users
|
||||
*
|
||||
* @param userIds - Array of user IDs
|
||||
* @returns Map of userId -> fitness goals array
|
||||
*/
|
||||
export async function batchLoadFitnessGoals(
|
||||
userIds: string[],
|
||||
): Promise<Map<string, any[]>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const db = await getDatabase();
|
||||
const goalsMap = new Map<string, any[]>();
|
||||
|
||||
// Initialize empty arrays for all user IDs
|
||||
userIds.forEach((userId) => goalsMap.set(userId, []));
|
||||
|
||||
// If batch method doesn't exist, fall back to individual queries
|
||||
if (!db.getFitnessGoalsByUserIds) {
|
||||
await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
const goals = await db.getFitnessGoalsByUserId(userId);
|
||||
goalsMap.set(userId, goals);
|
||||
}),
|
||||
);
|
||||
return goalsMap;
|
||||
}
|
||||
|
||||
const allGoals = await db.getFitnessGoalsByUserIds(userIds);
|
||||
|
||||
// Group by userId
|
||||
for (const goal of allGoals) {
|
||||
const userId = goal.userId;
|
||||
if (!goalsMap.has(userId)) {
|
||||
goalsMap.set(userId, []);
|
||||
}
|
||||
goalsMap.get(userId)!.push(goal);
|
||||
}
|
||||
|
||||
return goalsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load recommendations for multiple users
|
||||
*
|
||||
* @param userIds - Array of user IDs
|
||||
* @returns Map of userId -> recommendations array
|
||||
*/
|
||||
export async function batchLoadRecommendations(
|
||||
userIds: string[],
|
||||
): Promise<Map<string, any[]>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const db = await getDatabase();
|
||||
const recsMap = new Map<string, any[]>();
|
||||
|
||||
// Initialize empty arrays for all user IDs
|
||||
userIds.forEach((userId) => recsMap.set(userId, []));
|
||||
|
||||
// If batch method doesn't exist, fall back to individual queries
|
||||
if (!db.getRecommendationsByUserIds) {
|
||||
await Promise.all(
|
||||
userIds.map(async (userId) => {
|
||||
const recs = await db.getRecommendationsByUserId(userId);
|
||||
recsMap.set(userId, recs);
|
||||
}),
|
||||
);
|
||||
return recsMap;
|
||||
}
|
||||
|
||||
const allRecs = await db.getRecommendationsByUserIds(userIds);
|
||||
|
||||
// Group by userId
|
||||
for (const rec of allRecs) {
|
||||
const userId = rec.userId;
|
||||
if (!recsMap.has(userId)) {
|
||||
recsMap.set(userId, []);
|
||||
}
|
||||
recsMap.get(userId)!.push(rec);
|
||||
}
|
||||
|
||||
return recsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate attendance statistics from history
|
||||
*
|
||||
* @param attendanceHistory - Array of attendance records
|
||||
* @returns Statistics object
|
||||
*/
|
||||
export function calculateAttendanceStats(attendanceHistory: any[]) {
|
||||
const stats = {
|
||||
lastCheckInTime: null as Date | null,
|
||||
checkInsThisWeek: 0,
|
||||
checkInsThisMonth: 0,
|
||||
};
|
||||
|
||||
if (attendanceHistory.length === 0) {
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Last check-in is the most recent attendance record
|
||||
stats.lastCheckInTime = attendanceHistory[0].checkInTime;
|
||||
|
||||
// Calculate check-ins in last 7 days
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
stats.checkInsThisWeek = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
||||
).length;
|
||||
|
||||
// Calculate check-ins in last 30 days
|
||||
const monthAgo = new Date();
|
||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||
stats.checkInsThisMonth = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
||||
).length;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch load all user-related data in one operation
|
||||
*
|
||||
* @param userIds - Array of user IDs
|
||||
* @returns Object containing all batched data maps
|
||||
*
|
||||
* @example
|
||||
* const data = await batchLoadUserData(['user1', 'user2']);
|
||||
* const user1Client = data.clients.get('user1');
|
||||
* const user1CheckIn = data.activeCheckIns.get('user1');
|
||||
*/
|
||||
export async function batchLoadUserData(userIds: string[]) {
|
||||
const [clients, activeCheckIns, attendanceHistories] = await Promise.all([
|
||||
batchLoadClients(userIds),
|
||||
batchLoadActiveCheckIns(userIds),
|
||||
batchLoadAttendanceHistory(userIds),
|
||||
]);
|
||||
|
||||
return {
|
||||
clients,
|
||||
activeCheckIns,
|
||||
attendanceHistories,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Simple in-memory cache with TTL support
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const cache = new Cache<User[]>(5 * 60 * 1000); // 5 minutes TTL
|
||||
*
|
||||
* const users = await cache.getOrSet('all-users', async () => {
|
||||
* return await db.getAllUsers();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import log from "../logger";
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
hits: number;
|
||||
misses: number;
|
||||
size: number;
|
||||
hitRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple in-memory cache with TTL (Time To Live)
|
||||
*/
|
||||
export class Cache<T = any> {
|
||||
private store: Map<string, CacheEntry<T>> = new Map();
|
||||
private hits = 0;
|
||||
private misses = 0;
|
||||
private defaultTtl: number;
|
||||
|
||||
/**
|
||||
* Create a new cache instance
|
||||
*
|
||||
* @param defaultTtl - Default time-to-live in milliseconds (default: 5 minutes)
|
||||
*/
|
||||
constructor(defaultTtl: number = 5 * 60 * 1000) {
|
||||
this.defaultTtl = defaultTtl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from cache
|
||||
*
|
||||
* @param key - Cache key
|
||||
* @returns Cached value or null if expired/not found
|
||||
*/
|
||||
get(key: string): T | null {
|
||||
const entry = this.store.get(key);
|
||||
|
||||
if (!entry) {
|
||||
this.misses++;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
this.misses++;
|
||||
log.debug("Cache expired", { key });
|
||||
return null;
|
||||
}
|
||||
|
||||
this.hits++;
|
||||
log.debug("Cache hit", { key });
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in cache
|
||||
*
|
||||
* @param key - Cache key
|
||||
* @param value - Value to cache
|
||||
* @param ttl - Time-to-live in milliseconds (optional, uses default if not provided)
|
||||
*/
|
||||
set(key: string, value: T, ttl?: number): void {
|
||||
const expiresAt = Date.now() + (ttl ?? this.defaultTtl);
|
||||
this.store.set(key, {
|
||||
value,
|
||||
expiresAt,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
log.debug("Cache set", { key, ttl: ttl ?? this.defaultTtl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from cache, or compute and cache it if not found
|
||||
*
|
||||
* @param key - Cache key
|
||||
* @param fn - Function to compute value if not in cache
|
||||
* @param ttl - Time-to-live in milliseconds (optional)
|
||||
* @returns Cached or computed value
|
||||
*/
|
||||
async getOrSet(key: string, fn: () => Promise<T>, ttl?: number): Promise<T> {
|
||||
const cached = this.get(key);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
log.debug("Cache miss, computing value", { key });
|
||||
const value = await fn();
|
||||
this.set(key, value, ttl);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a value from cache
|
||||
*
|
||||
* @param key - Cache key
|
||||
* @returns True if deleted, false if not found
|
||||
*/
|
||||
delete(key: string): boolean {
|
||||
const deleted = this.store.delete(key);
|
||||
log.debug("Cache delete", { key, deleted });
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all entries from cache
|
||||
*/
|
||||
clear(): void {
|
||||
const size = this.store.size;
|
||||
this.store.clear();
|
||||
this.hits = 0;
|
||||
this.misses = 0;
|
||||
log.info("Cache cleared", { entriesCleared: size });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
getStats(): CacheStats {
|
||||
const total = this.hits + this.misses;
|
||||
return {
|
||||
hits: this.hits,
|
||||
misses: this.misses,
|
||||
size: this.store.size,
|
||||
hitRate: total > 0 ? this.hits / total : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expired entries from cache
|
||||
*/
|
||||
cleanup(): number {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
|
||||
for (const [key, entry] of Array.from(this.store.entries())) {
|
||||
if (now > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
log.info("Cache cleanup completed", { entriesRemoved: removed });
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in cache (and is not expired)
|
||||
*/
|
||||
has(key: string): boolean {
|
||||
const entry = this.store.get(key);
|
||||
if (!entry) return false;
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.store.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys in cache
|
||||
*/
|
||||
keys(): string[] {
|
||||
return Array.from(this.store.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache size (number of entries)
|
||||
*/
|
||||
size(): number {
|
||||
return this.store.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global cache instances for common use cases
|
||||
*/
|
||||
|
||||
// User data cache (5 minutes)
|
||||
export const userCache = new Cache<any>(5 * 60 * 1000);
|
||||
|
||||
// Fitness goals cache (2 minutes)
|
||||
export const fitnessGoalsCache = new Cache<any>(2 * 60 * 1000);
|
||||
|
||||
// Recommendations cache (3 minutes)
|
||||
export const recommendationsCache = new Cache<any>(3 * 60 * 1000);
|
||||
|
||||
// Dashboard stats cache (1 minute - frequently changing data)
|
||||
export const dashboardStatsCache = new Cache<any>(1 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* Setup automatic cache cleanup (run every 5 minutes)
|
||||
*/
|
||||
export function setupCacheCleanup(): NodeJS.Timeout {
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
log.debug("Running scheduled cache cleanup");
|
||||
userCache.cleanup();
|
||||
fitnessGoalsCache.cleanup();
|
||||
recommendationsCache.cleanup();
|
||||
dashboardStatsCache.cleanup();
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
); // Every 5 minutes
|
||||
|
||||
return interval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
export function clearAllCaches(): void {
|
||||
userCache.clear();
|
||||
fitnessGoalsCache.clear();
|
||||
recommendationsCache.clear();
|
||||
dashboardStatsCache.clear();
|
||||
log.info("All caches cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stats for all caches
|
||||
*/
|
||||
export function getAllCacheStats(): Record<string, CacheStats> {
|
||||
return {
|
||||
users: userCache.getStats(),
|
||||
fitnessGoals: fitnessGoalsCache.getStats(),
|
||||
recommendations: recommendationsCache.getStats(),
|
||||
dashboardStats: dashboardStatsCache.getStats(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Performance utilities for FitAI Admin App
|
||||
*
|
||||
* This module provides:
|
||||
* - Performance monitoring and timing utilities
|
||||
* - In-memory caching with TTL support
|
||||
* - Batch query helpers for database operations
|
||||
*/
|
||||
|
||||
export * from "./monitoring";
|
||||
export * from "./cache";
|
||||
export * from "./batch-queries";
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Performance monitoring utilities for tracking execution time and metrics
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const timer = startTimer('database-query');
|
||||
* // ... do work
|
||||
* timer.end(); // logs duration
|
||||
*
|
||||
* // Or with async functions:
|
||||
* const result = await measureAsync('fetch-users', async () => {
|
||||
* return await db.getAllUsers();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import log from "../logger";
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
operation: string;
|
||||
duration: number;
|
||||
timestamp: Date;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Store recent metrics in memory (last 1000 operations)
|
||||
const metricsHistory: PerformanceMetrics[] = [];
|
||||
const MAX_HISTORY_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* Timer for tracking operation duration
|
||||
*/
|
||||
export interface Timer {
|
||||
operation: string;
|
||||
startTime: number;
|
||||
end: (metadata?: Record<string, any>) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a performance timer
|
||||
*
|
||||
* @param operation - Name of the operation being timed
|
||||
* @returns Timer object with end() method
|
||||
*
|
||||
* @example
|
||||
* const timer = startTimer('database-query');
|
||||
* const users = await db.getAllUsers();
|
||||
* timer.end({ count: users.length });
|
||||
*/
|
||||
export function startTimer(operation: string): Timer {
|
||||
const startTime = performance.now();
|
||||
|
||||
return {
|
||||
operation,
|
||||
startTime,
|
||||
end(metadata?: Record<string, any>): number {
|
||||
const duration = performance.now() - startTime;
|
||||
recordMetric(operation, duration, metadata);
|
||||
return duration;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the execution time of a synchronous function
|
||||
*
|
||||
* @param operation - Name of the operation
|
||||
* @param fn - Function to execute and measure
|
||||
* @returns Result of the function
|
||||
*/
|
||||
export function measure<T>(operation: string, fn: () => T): T {
|
||||
const timer = startTimer(operation);
|
||||
const result = fn();
|
||||
timer.end();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure the execution time of an async function
|
||||
*
|
||||
* @param operation - Name of the operation
|
||||
* @param fn - Async function to execute and measure
|
||||
* @param metadata - Additional metadata to log
|
||||
* @returns Result of the function
|
||||
*
|
||||
* @example
|
||||
* const users = await measureAsync('fetch-users', async () => {
|
||||
* return await db.getAllUsers();
|
||||
* }, { limit: 100 });
|
||||
*/
|
||||
export async function measureAsync<T>(
|
||||
operation: string,
|
||||
fn: () => Promise<T>,
|
||||
metadata?: Record<string, any>,
|
||||
): Promise<T> {
|
||||
const timer = startTimer(operation);
|
||||
try {
|
||||
const result = await fn();
|
||||
timer.end(metadata);
|
||||
return result;
|
||||
} catch (error) {
|
||||
timer.end({ ...metadata, error: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a performance metric
|
||||
*/
|
||||
function recordMetric(
|
||||
operation: string,
|
||||
duration: number,
|
||||
metadata?: Record<string, any>,
|
||||
): void {
|
||||
const metric: PerformanceMetrics = {
|
||||
operation,
|
||||
duration,
|
||||
timestamp: new Date(),
|
||||
metadata,
|
||||
};
|
||||
|
||||
// Add to history (keep last MAX_HISTORY_SIZE entries)
|
||||
metricsHistory.push(metric);
|
||||
if (metricsHistory.length > MAX_HISTORY_SIZE) {
|
||||
metricsHistory.shift();
|
||||
}
|
||||
|
||||
// Log based on duration (warn for slow operations)
|
||||
if (duration > 1000) {
|
||||
log.warn(`Slow operation: ${operation}`, {
|
||||
duration: `${duration.toFixed(2)}ms`,
|
||||
...metadata,
|
||||
});
|
||||
} else if (duration > 500) {
|
||||
log.info(`Operation: ${operation}`, {
|
||||
duration: `${duration.toFixed(2)}ms`,
|
||||
...metadata,
|
||||
});
|
||||
} else {
|
||||
log.debug(`Operation: ${operation}`, {
|
||||
duration: `${duration.toFixed(2)}ms`,
|
||||
...metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance statistics for a specific operation
|
||||
*/
|
||||
export function getOperationStats(operation: string): {
|
||||
count: number;
|
||||
avgDuration: number;
|
||||
minDuration: number;
|
||||
maxDuration: number;
|
||||
totalDuration: number;
|
||||
} | null {
|
||||
const metrics = metricsHistory.filter((m) => m.operation === operation);
|
||||
|
||||
if (metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durations = metrics.map((m) => m.duration);
|
||||
const totalDuration = durations.reduce((sum, d) => sum + d, 0);
|
||||
|
||||
return {
|
||||
count: metrics.length,
|
||||
avgDuration: totalDuration / metrics.length,
|
||||
minDuration: Math.min(...durations),
|
||||
maxDuration: Math.max(...durations),
|
||||
totalDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recent metrics (last MAX_HISTORY_SIZE operations)
|
||||
*/
|
||||
export function getRecentMetrics(limit?: number): PerformanceMetrics[] {
|
||||
const count = limit ?? metricsHistory.length;
|
||||
return metricsHistory.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated statistics for all operations
|
||||
*/
|
||||
export function getAllStats(): Record<
|
||||
string,
|
||||
{
|
||||
count: number;
|
||||
avgDuration: number;
|
||||
minDuration: number;
|
||||
maxDuration: number;
|
||||
}
|
||||
> {
|
||||
const operations = Array.from(
|
||||
new Set(metricsHistory.map((m) => m.operation)),
|
||||
);
|
||||
const stats: Record<string, any> = {};
|
||||
|
||||
for (const operation of operations) {
|
||||
const opStats = getOperationStats(operation);
|
||||
if (opStats) {
|
||||
stats[operation] = {
|
||||
count: opStats.count,
|
||||
avgDuration: opStats.avgDuration,
|
||||
minDuration: opStats.minDuration,
|
||||
maxDuration: opStats.maxDuration,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all metrics history
|
||||
*/
|
||||
export function clearMetrics(): void {
|
||||
metricsHistory.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator for measuring class methods (experimental)
|
||||
*/
|
||||
export function Measure(operation?: string) {
|
||||
return function (
|
||||
target: any,
|
||||
propertyKey: string,
|
||||
descriptor: PropertyDescriptor,
|
||||
) {
|
||||
const originalMethod = descriptor.value;
|
||||
const opName = operation || `${target.constructor.name}.${propertyKey}`;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
return measureAsync(opName, () => originalMethod.apply(this, args));
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
@@ -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