enhancment complete with phase 10

This commit is contained in:
dimitar committed 2026-03-10 05:37:22 +01:00
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 });
+22 -20
View File
@@ -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();
}
}
+13 -8
View File
@@ -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());
}
}
+42 -45
View File
@@ -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());
}
}
+29 -19
View File
@@ -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,
);
+21 -13
View File
@@ -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(
+65 -30
View File
@@ -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);
});
});
});
+150 -145
View File
@@ -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],
);
+112 -97
View File
@@ -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>
);
}
+5 -3
View File
@@ -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:
+8
View File
@@ -0,0 +1,8 @@
/**
* API Response Utilities
*
* Standardized response formatting for FitAI Admin API
*/
export * from "./types";
export * from "./responses";
+364
View File
@@ -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,
});
}
+200
View File
@@ -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);
});
});
});
});
+73 -1
View File
@@ -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;
+5
View File
@@ -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>,
+4 -11
View File
@@ -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,
};
}
+253
View File
@@ -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(),
};
}
+12
View File
@@ -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;
};
}
+202
View File
@@ -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 };
}
+371
View File
@@ -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>;