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

+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>;