phase 1-6 implemented
This commit is contained in:
1 parent
22c274bb83
commit
2c4927478a
73 files changed
+9702
-2354
No files matched your search
@@ -1,42 +1,52 @@
|
||||
import type { AIContext } from './ai-context';
|
||||
import { formatGoalForPrompt } from './ai-context';
|
||||
import type { AIContext } from "./ai-context";
|
||||
import type { FitnessProfile } from "../database/types";
|
||||
import { formatGoalForPrompt } from "./ai-context";
|
||||
|
||||
/**
|
||||
* Build enhanced AI prompt with comprehensive user context
|
||||
* Includes profile, goals, progress, and recommendation history
|
||||
*/
|
||||
export function buildEnhancedPrompt(context: AIContext): string {
|
||||
const { profile, activeGoals, completedGoals, recentRecommendations, progressSummary } = context;
|
||||
const {
|
||||
profile,
|
||||
activeGoals,
|
||||
completedGoals,
|
||||
recentRecommendations,
|
||||
progressSummary,
|
||||
} = context;
|
||||
|
||||
// Build goals section
|
||||
const activeGoalsText =
|
||||
activeGoals.length > 0
|
||||
? activeGoals.map((g) => `- ${formatGoalForPrompt(g)}`).join('\n')
|
||||
: '- No active goals set';
|
||||
// Build goals section
|
||||
const activeGoalsText =
|
||||
activeGoals.length > 0
|
||||
? activeGoals.map((g) => `- ${formatGoalForPrompt(g)}`).join("\n")
|
||||
: "- No active goals set";
|
||||
|
||||
const completedGoalsText =
|
||||
completedGoals.length > 0
|
||||
? completedGoals.slice(0, 3).map((g) => g.title).join(', ')
|
||||
: 'None yet';
|
||||
const completedGoalsText =
|
||||
completedGoals.length > 0
|
||||
? completedGoals
|
||||
.slice(0, 3)
|
||||
.map((g) => g.title)
|
||||
.join(", ")
|
||||
: "None yet";
|
||||
|
||||
// Build context about recent recommendations
|
||||
const recommendationContextText =
|
||||
recentRecommendations.length > 0
|
||||
? `The user has received ${recentRecommendations.length} recommendations in the past week. Consider their progress and avoid repetitive advice.`
|
||||
: 'This is a new user or they haven\'t received recent recommendations. Provide comprehensive guidance.';
|
||||
// Build context about recent recommendations
|
||||
const recommendationContextText =
|
||||
recentRecommendations.length > 0
|
||||
? `The user has received ${recentRecommendations.length} recommendations in the past week. Consider their progress and avoid repetitive advice.`
|
||||
: "This is a new user or they haven't received recent recommendations. Provide comprehensive guidance.";
|
||||
|
||||
return `You are a professional fitness trainer and nutritionist with access to the user's complete fitness journey.
|
||||
return `You are a professional fitness trainer and nutritionist with access to the user's complete fitness journey.
|
||||
|
||||
## User Profile
|
||||
- Height: ${profile.height || 'Not specified'} cm
|
||||
- Weight: ${profile.weight || 'Not specified'} kg
|
||||
- Age: ${profile.age || 'Not specified'}
|
||||
- Gender: ${profile.gender || 'Not specified'}
|
||||
- Primary Goal: ${profile.fitnessGoals?.[0] || 'General fitness'}
|
||||
- Activity Level: ${profile.activityLevel || 'Not specified'}
|
||||
- Medical Conditions: ${profile.medicalConditions || 'None'}
|
||||
- Allergies: ${profile.allergies || 'None'}
|
||||
- Injuries: ${profile.injuries || 'None'}
|
||||
- Height: ${profile.height || "Not specified"} cm
|
||||
- Weight: ${profile.weight || "Not specified"} kg
|
||||
- Age: ${profile.age || "Not specified"}
|
||||
- Gender: ${profile.gender || "Not specified"}
|
||||
- Primary Goal: ${profile.fitnessGoals?.[0] || "General fitness"}
|
||||
- Activity Level: ${profile.activityLevel || "Not specified"}
|
||||
- Medical Conditions: ${profile.medicalConditions || "None"}
|
||||
- Allergies: ${profile.allergies || "None"}
|
||||
- Injuries: ${profile.injuries || "None"}
|
||||
|
||||
## Active Goals (${activeGoals.length})
|
||||
${activeGoalsText}
|
||||
@@ -45,7 +55,7 @@ ${activeGoalsText}
|
||||
- Goals Completed: ${progressSummary.goalsCompleted}
|
||||
- Active Goals: ${progressSummary.goalsActive}
|
||||
- Average Progress on Active Goals: ${progressSummary.averageProgress.toFixed(1)}%
|
||||
${completedGoals.length > 0 ? `- Recently Completed: ${completedGoalsText}` : ''}
|
||||
${completedGoals.length > 0 ? `- Recently Completed: ${completedGoalsText}` : ""}
|
||||
|
||||
## Recommendation History
|
||||
${recommendationContextText}
|
||||
@@ -70,8 +80,8 @@ Respond in the following JSON format ONLY, no other text. Do not use markdown fo
|
||||
* Build basic prompt for users without complete context
|
||||
* Fallback when goals or other data is missing
|
||||
*/
|
||||
export function buildBasicPrompt(profile: any): string {
|
||||
return `You are a professional fitness trainer and nutritionist.
|
||||
export function buildBasicPrompt(profile: FitnessProfile): string {
|
||||
return `You are a professional fitness trainer and nutritionist.
|
||||
Generate a detailed daily recommendation for a user with the following profile:
|
||||
- Height: ${profile.height} cm
|
||||
- Weight: ${profile.weight} kg
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { auth, currentUser } from "@clerk/nextjs/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import log from "./logger";
|
||||
|
||||
/**
|
||||
* Get authenticated user ID from request
|
||||
@@ -15,19 +16,21 @@ export async function getAuthUserId(req: NextRequest): Promise<string | null> {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (userId) {
|
||||
console.log("✓ Authenticated user:", userId);
|
||||
log.debug("Authenticated user", { userId });
|
||||
return userId;
|
||||
}
|
||||
|
||||
console.log("✗ No authentication found");
|
||||
log.debug("No authentication found");
|
||||
|
||||
// Log headers for debugging
|
||||
const authHeader = req.headers.get("authorization");
|
||||
console.log("Authorization header:", authHeader ? "Present" : "Missing");
|
||||
log.debug("Authorization header check", {
|
||||
present: !!authHeader,
|
||||
});
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Authentication error:", error);
|
||||
log.error("Authentication error", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { USER_ROLES } from "@fitai/shared";
|
||||
|
||||
export type UserRole = (typeof USER_ROLES)[number];
|
||||
|
||||
interface AuthResult {
|
||||
userId: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require authentication and optionally check user role
|
||||
*
|
||||
* @param allowedRoles - Array of roles allowed to access the endpoint (optional)
|
||||
* @returns Authentication result with userId and role, or NextResponse error
|
||||
*
|
||||
* @example
|
||||
* export async function DELETE(request: NextRequest) {
|
||||
* const authResult = await requireAuth(["admin", "superAdmin"]);
|
||||
* if (authResult instanceof NextResponse) return authResult;
|
||||
* const { userId, role } = authResult;
|
||||
* // ... proceed with authorized logic
|
||||
* }
|
||||
*/
|
||||
export async function requireAuth(
|
||||
allowedRoles?: UserRole[],
|
||||
): Promise<AuthResult | NextResponse> {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized - Authentication required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get user role from Clerk metadata
|
||||
const { sessionClaims } = await auth();
|
||||
const role = (sessionClaims?.metadata as { role?: UserRole })?.role;
|
||||
|
||||
if (!role) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden - User role not found" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user has required role
|
||||
if (allowedRoles && allowedRoles.length > 0) {
|
||||
if (!allowedRoles.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Forbidden - Requires one of: ${allowedRoles.join(", ")}`,
|
||||
requiredRoles: allowedRoles,
|
||||
userRole: role,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { userId, role };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the authenticated user is trying to modify their own account
|
||||
* Useful for preventing self-deletion or self-demotion
|
||||
*
|
||||
* @param userId - Authenticated user's ID
|
||||
* @param targetUserId - ID of user being modified
|
||||
* @returns true if user is modifying themselves
|
||||
*/
|
||||
export function isSelfModification(
|
||||
userId: string,
|
||||
targetUserId: string,
|
||||
): boolean {
|
||||
return userId === targetUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an admin isn't demoting themselves or deleting their own account
|
||||
*
|
||||
* @param userId - Authenticated user's ID
|
||||
* @param targetUserId - ID of user being modified
|
||||
* @param action - Action being performed (e.g., "delete", "demote")
|
||||
* @returns NextResponse error if invalid, undefined if valid
|
||||
*/
|
||||
export function preventSelfModification(
|
||||
userId: string,
|
||||
targetUserId: string,
|
||||
action: string,
|
||||
): NextResponse | undefined {
|
||||
if (isSelfModification(userId, targetUserId)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Cannot ${action} your own account`,
|
||||
hint: "Ask another administrator to perform this action",
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { clerkClient } from '@clerk/nextjs/server';
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import log from "./logger";
|
||||
|
||||
/**
|
||||
* User roles available in the application
|
||||
*/
|
||||
export type UserRole = 'admin' | 'trainer' | 'client';
|
||||
export type UserRole = "admin" | "trainer" | "client";
|
||||
|
||||
/**
|
||||
* Set a user's role in Clerk public metadata
|
||||
@@ -52,7 +53,10 @@ export async function getUserRole(userId: string): Promise<UserRole | null> {
|
||||
* @example
|
||||
* const isAdmin = await hasRole('user_abc123', 'admin');
|
||||
*/
|
||||
export async function hasRole(userId: string, role: UserRole): Promise<boolean> {
|
||||
export async function hasRole(
|
||||
userId: string,
|
||||
role: UserRole,
|
||||
): Promise<boolean> {
|
||||
const userRole = await getUserRole(userId);
|
||||
return userRole === role;
|
||||
}
|
||||
@@ -67,7 +71,7 @@ export async function hasRole(userId: string, role: UserRole): Promise<boolean>
|
||||
* const isAdmin = await isAdmin('user_abc123');
|
||||
*/
|
||||
export async function isAdmin(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'admin');
|
||||
return hasRole(userId, "admin");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,7 +84,7 @@ export async function isAdmin(userId: string): Promise<boolean> {
|
||||
* const isTrainer = await isTrainer('user_abc123');
|
||||
*/
|
||||
export async function isTrainer(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'trainer');
|
||||
return hasRole(userId, "trainer");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,7 +97,7 @@ export async function isTrainer(userId: string): Promise<boolean> {
|
||||
* const isClient = await isClient('user_abc123');
|
||||
*/
|
||||
export async function isClient(userId: string): Promise<boolean> {
|
||||
return hasRole(userId, 'client');
|
||||
return hasRole(userId, "client");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +113,7 @@ export async function isClient(userId: string): Promise<boolean> {
|
||||
* ]);
|
||||
*/
|
||||
export async function bulkSetUserRoles(
|
||||
userRoles: Array<{ userId: string; role: UserRole }>
|
||||
userRoles: Array<{ userId: string; role: UserRole }>,
|
||||
): Promise<void> {
|
||||
const client = await clerkClient();
|
||||
|
||||
@@ -117,8 +121,8 @@ export async function bulkSetUserRoles(
|
||||
userRoles.map(({ userId, role }) =>
|
||||
client.users.updateUser(userId, {
|
||||
publicMetadata: { role },
|
||||
})
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,7 +143,7 @@ export async function getUsersByRole(role: UserRole) {
|
||||
const { data: users } = await client.users.getUserList();
|
||||
|
||||
return users.filter(
|
||||
(user) => (user.publicMetadata?.role as UserRole) === role
|
||||
(user) => (user.publicMetadata?.role as UserRole) === role,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,7 +167,7 @@ export async function getUserCountByRole(): Promise<Record<UserRole, number>> {
|
||||
};
|
||||
|
||||
users.forEach((user) => {
|
||||
const role = (user.publicMetadata?.role as UserRole) || 'client';
|
||||
const role = (user.publicMetadata?.role as UserRole) || "client";
|
||||
counts[role]++;
|
||||
});
|
||||
|
||||
@@ -192,7 +196,7 @@ export async function syncUserRole(userId: string): Promise<boolean> {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error syncing user role:', error);
|
||||
log.error("Failed to sync user role", error, { userId });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,114 @@ import {
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
asc,
|
||||
sql,
|
||||
} from "@fitai/database";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
import { SQL, or, like, inArray, gte, lte } from "drizzle-orm";
|
||||
import type { SortConfig, FilterCondition } from "../filtering";
|
||||
import { buildWhereConditions, buildSearchCondition } from "../filtering";
|
||||
import log from "../logger";
|
||||
|
||||
// Database row types (before mapping to domain types)
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password: string | null;
|
||||
phone: string | null;
|
||||
role: string;
|
||||
gymId?: string | null;
|
||||
gym_id?: string | null; // Alternative column name
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
interface ClientRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
membershipType: string;
|
||||
membershipStatus: string;
|
||||
joinDate: number | Date;
|
||||
lastVisit?: number | Date | null;
|
||||
emergencyContactName?: string | null;
|
||||
emergencyContactPhone?: string | null;
|
||||
emergencyContactRelationship?: string | null;
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
interface FitnessProfileRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
height?: number | null;
|
||||
weight?: number | null;
|
||||
age?: number | null;
|
||||
goals?: string | null;
|
||||
medicalConditions?: string | null;
|
||||
dietaryRestrictions?: string | null;
|
||||
activityLevel?: string | null;
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
interface AttendanceRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
checkInTime: number | Date;
|
||||
checkOutTime?: number | Date | null;
|
||||
notes?: string | null;
|
||||
createdAt: number | Date;
|
||||
}
|
||||
|
||||
interface RecommendationRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId: string;
|
||||
recommendationText: string;
|
||||
activityPlan: string;
|
||||
dietPlan: string;
|
||||
status: string;
|
||||
generatedAt: number | Date;
|
||||
approvedBy?: string | null;
|
||||
approvedAt?: number | Date | null;
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
interface FitnessGoalRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string | null;
|
||||
goalType: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
targetValue?: number | null;
|
||||
currentValue?: number | null;
|
||||
unit?: string | null;
|
||||
status: string;
|
||||
progress: number | null;
|
||||
priority: string;
|
||||
startDate: number | Date;
|
||||
targetDate?: number | Date | null;
|
||||
completedDate?: number | Date | null;
|
||||
notes?: string | null;
|
||||
createdAt: number | Date;
|
||||
updatedAt: number | Date;
|
||||
}
|
||||
|
||||
// Type for PRAGMA table_info result
|
||||
interface TableInfoRow {
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
}
|
||||
|
||||
// Type for raw SQL results with unknown structure
|
||||
type RawSQLRow = Record<string, unknown>;
|
||||
|
||||
export class DrizzleDatabase implements IDatabase {
|
||||
private config: DatabaseConfig;
|
||||
@@ -36,7 +141,7 @@ export class DrizzleDatabase implements IDatabase {
|
||||
// Drizzle with better-sqlite3 connects synchronously on initialization
|
||||
// We can just log here if needed
|
||||
if (this.config.options?.logging) {
|
||||
console.log("Drizzle database connected");
|
||||
log.info("Drizzle database connected");
|
||||
}
|
||||
await this.createTables();
|
||||
}
|
||||
@@ -46,7 +151,7 @@ export class DrizzleDatabase implements IDatabase {
|
||||
// but we can close the underlying sqlite instance if we had access to it.
|
||||
// For now, we'll assume it's handled.
|
||||
if (this.config.options?.logging) {
|
||||
console.log("Drizzle database disconnected");
|
||||
log.info("Drizzle database disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +349,268 @@ export class DrizzleDatabase implements IDatabase {
|
||||
.run();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with pagination, sorting, and filtering
|
||||
* Uses SQL LIMIT/OFFSET for efficient pagination
|
||||
*/
|
||||
async getUsersWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
role?: string;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ users: User[]; total: number }> {
|
||||
const { page, limit, role, sort, filters, search } = params;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
// Add role filter if provided
|
||||
if (role) {
|
||||
whereConditions.push(eq(users.role, role as User["role"]));
|
||||
}
|
||||
|
||||
// Add filter conditions from query params
|
||||
if (filters && filters.length > 0) {
|
||||
const columnMap = {
|
||||
role: users.role,
|
||||
email: users.email,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
phone: users.phone,
|
||||
gymId: users.gymId,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
};
|
||||
const filterCondition = buildWhereConditions(filters, columnMap);
|
||||
if (filterCondition) {
|
||||
whereConditions.push(filterCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Add search condition
|
||||
if (search) {
|
||||
const searchFields = ["email", "firstName", "lastName", "phone"];
|
||||
const searchCondition = buildSearchCondition(search, searchFields, {
|
||||
email: users.email,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
phone: users.phone,
|
||||
});
|
||||
if (searchCondition) {
|
||||
whereConditions.push(searchCondition);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? and(...whereConditions) : undefined;
|
||||
|
||||
// Build ORDER BY clause
|
||||
const sortColumn = sort?.field
|
||||
? {
|
||||
email: users.email,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
}[sort.field]
|
||||
: users.createdAt;
|
||||
|
||||
const orderBy =
|
||||
sort?.direction === "asc"
|
||||
? asc(sortColumn || users.createdAt)
|
||||
: desc(sortColumn || users.createdAt);
|
||||
|
||||
// Get total count (without pagination)
|
||||
const countQuery = whereClause
|
||||
? this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(whereClause)
|
||||
: this.db.select({ count: sql<number>`count(*)` }).from(users);
|
||||
|
||||
const countResult = await countQuery.get();
|
||||
const total = countResult?.count ?? 0;
|
||||
|
||||
// Get paginated results
|
||||
const query = this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const results = whereClause
|
||||
? await query.where(whereClause).all()
|
||||
: await query.all();
|
||||
|
||||
return {
|
||||
users: results.map(this.mapUser),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users with their client and attendance data in optimized batches
|
||||
* Avoids N+1 query problem by batching related data queries
|
||||
* Now supports sorting, filtering, and search
|
||||
*/
|
||||
async getUsersWithRelatedData(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
role?: string;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
users: Array<
|
||||
User & {
|
||||
client?: Client | null;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: Date | null;
|
||||
lastCheckInTime?: Date | null;
|
||||
checkInsThisWeek?: number;
|
||||
checkInsThisMonth?: number;
|
||||
}
|
||||
>;
|
||||
total?: number;
|
||||
}> {
|
||||
const page = params?.page ?? 1;
|
||||
const limit = params?.limit ?? 1000;
|
||||
const role = params?.role;
|
||||
const sort = params?.sort;
|
||||
const filters = params?.filters;
|
||||
const search = params?.search;
|
||||
|
||||
// Step 1: Get paginated users with sorting and filtering
|
||||
const { users: paginatedUsers, total } = await this.getUsersWithPagination({
|
||||
page,
|
||||
limit,
|
||||
role,
|
||||
sort,
|
||||
filters,
|
||||
search,
|
||||
});
|
||||
|
||||
if (paginatedUsers.length === 0) {
|
||||
return { users: [], total: 0 };
|
||||
}
|
||||
|
||||
const userIds = paginatedUsers.map((u) => u.id);
|
||||
|
||||
// Step 2: Batch fetch clients for all users (1 query instead of N)
|
||||
const allClients = await this.db.select().from(clients).all();
|
||||
const clientsByUserId = new Map(
|
||||
allClients.map((c) => [c.userId, this.mapClient(c)]),
|
||||
);
|
||||
|
||||
// Step 3: Batch fetch attendance stats (2 queries instead of 2N)
|
||||
const attendanceStats = await this.getAttendanceStatsBatch(userIds);
|
||||
|
||||
// Step 4: Combine all data
|
||||
const usersWithData = paginatedUsers.map((user) => {
|
||||
const client = clientsByUserId.get(user.id) || null;
|
||||
const stats = attendanceStats.get(user.id) || {
|
||||
isCheckedIn: false,
|
||||
checkInTime: null,
|
||||
lastCheckInTime: null,
|
||||
checkInsThisWeek: 0,
|
||||
checkInsThisMonth: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
...user,
|
||||
client,
|
||||
...stats,
|
||||
};
|
||||
});
|
||||
|
||||
return { users: usersWithData, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attendance statistics for multiple users efficiently
|
||||
* Uses aggregation queries to avoid N+1 problem
|
||||
*/
|
||||
async getAttendanceStatsBatch(userIds: string[]): Promise<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
isCheckedIn: boolean;
|
||||
checkInTime: Date | null;
|
||||
lastCheckInTime: Date | null;
|
||||
checkInsThisWeek: number;
|
||||
checkInsThisMonth: number;
|
||||
}
|
||||
>
|
||||
> {
|
||||
if (userIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Fetch ALL attendance records for these users (2 queries total instead of 2N)
|
||||
const allAttendance = await this.db.select().from(attendance).all();
|
||||
|
||||
// Filter to relevant users
|
||||
const relevantAttendance = allAttendance.filter((a) =>
|
||||
userIds.includes(a.userId),
|
||||
);
|
||||
|
||||
const statsMap = new Map();
|
||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const userId of userIds) {
|
||||
const userAttendance = relevantAttendance.filter(
|
||||
(a) => a.userId === userId,
|
||||
);
|
||||
|
||||
// Find active check-in
|
||||
const activeCheckIn = userAttendance.find((a) => !a.checkOutTime);
|
||||
|
||||
// Get last check-in time
|
||||
const sortedAttendance = userAttendance.sort(
|
||||
(a, b) =>
|
||||
new Date(b.checkInTime).getTime() - new Date(a.checkInTime).getTime(),
|
||||
);
|
||||
const lastCheckInTime = sortedAttendance[0]?.checkInTime || null;
|
||||
|
||||
// Count check-ins
|
||||
const checkInsThisWeek = userAttendance.filter(
|
||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
||||
).length;
|
||||
const checkInsThisMonth = userAttendance.filter(
|
||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
||||
).length;
|
||||
|
||||
statsMap.set(userId, {
|
||||
isCheckedIn: !!activeCheckIn,
|
||||
checkInTime: activeCheckIn?.checkInTime || null,
|
||||
lastCheckInTime,
|
||||
checkInsThisWeek,
|
||||
checkInsThisMonth,
|
||||
});
|
||||
}
|
||||
|
||||
// Fill in missing users with default values
|
||||
for (const userId of userIds) {
|
||||
if (!statsMap.has(userId)) {
|
||||
statsMap.set(userId, {
|
||||
isCheckedIn: false,
|
||||
checkInTime: null,
|
||||
lastCheckInTime: null,
|
||||
checkInsThisWeek: 0,
|
||||
checkInsThisMonth: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return statsMap;
|
||||
}
|
||||
|
||||
// Client operations
|
||||
async createClient(clientData: Omit<Client, "id">): Promise<Client> {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
@@ -285,6 +652,101 @@ export class DrizzleDatabase implements IDatabase {
|
||||
return results.map(this.mapClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clients with pagination, sorting, and filtering
|
||||
*/
|
||||
async getClientsWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ clients: Client[]; total: number }> {
|
||||
const { page, limit, sort, filters, search } = params;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
// Add filter conditions
|
||||
if (filters && filters.length > 0) {
|
||||
const columnMap = {
|
||||
userId: clients.userId,
|
||||
membershipType: clients.membershipType,
|
||||
membershipStatus: clients.membershipStatus,
|
||||
joinDate: clients.joinDate,
|
||||
lastVisit: clients.lastVisit,
|
||||
};
|
||||
const filterCondition = buildWhereConditions(filters, columnMap);
|
||||
if (filterCondition) {
|
||||
whereConditions.push(filterCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Add search condition (search by user ID or emergency contact)
|
||||
if (search) {
|
||||
const searchCondition = buildSearchCondition(
|
||||
search,
|
||||
["userId", "emergencyContactName", "emergencyContactPhone"],
|
||||
{
|
||||
userId: clients.userId,
|
||||
emergencyContactName: clients.emergencyContactName,
|
||||
emergencyContactPhone: clients.emergencyContactPhone,
|
||||
},
|
||||
);
|
||||
if (searchCondition) {
|
||||
whereConditions.push(searchCondition);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? and(...whereConditions) : undefined;
|
||||
|
||||
// Build ORDER BY clause
|
||||
const sortColumn = sort?.field
|
||||
? {
|
||||
joinDate: clients.joinDate,
|
||||
lastVisit: clients.lastVisit,
|
||||
membershipType: clients.membershipType,
|
||||
membershipStatus: clients.membershipStatus,
|
||||
userId: clients.userId,
|
||||
}[sort.field]
|
||||
: clients.joinDate;
|
||||
|
||||
const orderBy =
|
||||
sort?.direction === "asc"
|
||||
? asc(sortColumn || clients.joinDate)
|
||||
: desc(sortColumn || clients.joinDate);
|
||||
|
||||
// Get total count
|
||||
const countQuery = whereClause
|
||||
? this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(clients)
|
||||
.where(whereClause)
|
||||
: this.db.select({ count: sql<number>`count(*)` }).from(clients);
|
||||
|
||||
const countResult = await countQuery.get();
|
||||
const total = countResult?.count ?? 0;
|
||||
|
||||
// Get paginated results
|
||||
const query = this.db
|
||||
.select()
|
||||
.from(clients)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const results = whereClause
|
||||
? await query.where(whereClause).all()
|
||||
: await query.all();
|
||||
|
||||
return {
|
||||
clients: results.map(this.mapClient),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async updateClient(
|
||||
id: string,
|
||||
updates: Partial<Client>,
|
||||
@@ -444,6 +906,98 @@ export class DrizzleDatabase implements IDatabase {
|
||||
return results.map(this.mapAttendance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get attendance records with pagination, sorting, and filtering
|
||||
*/
|
||||
async getAttendanceWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ attendance: Attendance[]; total: number }> {
|
||||
const { page, limit, sort, filters, search } = params;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build WHERE conditions
|
||||
const whereConditions: any[] = [];
|
||||
|
||||
// Add filter conditions
|
||||
if (filters && filters.length > 0) {
|
||||
const columnMap = {
|
||||
userId: attendance.userId,
|
||||
type: attendance.type,
|
||||
checkInTime: attendance.checkInTime,
|
||||
checkOutTime: attendance.checkOutTime,
|
||||
};
|
||||
const filterCondition = buildWhereConditions(filters, columnMap);
|
||||
if (filterCondition) {
|
||||
whereConditions.push(filterCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Add search condition (search by user ID or notes)
|
||||
if (search) {
|
||||
const searchCondition = buildSearchCondition(
|
||||
search,
|
||||
["userId", "notes"],
|
||||
{
|
||||
userId: attendance.userId,
|
||||
notes: attendance.notes,
|
||||
},
|
||||
);
|
||||
if (searchCondition) {
|
||||
whereConditions.push(searchCondition);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? and(...whereConditions) : undefined;
|
||||
|
||||
// Build ORDER BY clause
|
||||
const sortColumn = sort?.field
|
||||
? {
|
||||
checkInTime: attendance.checkInTime,
|
||||
checkOutTime: attendance.checkOutTime,
|
||||
type: attendance.type,
|
||||
userId: attendance.userId,
|
||||
}[sort.field]
|
||||
: attendance.checkInTime;
|
||||
|
||||
const orderBy =
|
||||
sort?.direction === "asc"
|
||||
? asc(sortColumn || attendance.checkInTime)
|
||||
: desc(sortColumn || attendance.checkInTime);
|
||||
|
||||
// Get total count
|
||||
const countQuery = whereClause
|
||||
? this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(attendance)
|
||||
.where(whereClause)
|
||||
: this.db.select({ count: sql<number>`count(*)` }).from(attendance);
|
||||
|
||||
const countResult = await countQuery.get();
|
||||
const total = countResult?.count ?? 0;
|
||||
|
||||
// Get paginated results
|
||||
const query = this.db
|
||||
.select()
|
||||
.from(attendance)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const results = whereClause
|
||||
? await query.where(whereClause).all()
|
||||
: await query.all();
|
||||
|
||||
return {
|
||||
attendance: results.map(this.mapAttendance),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async getActiveCheckIn(userId: string): Promise<Attendance | null> {
|
||||
// Drizzle doesn't support IS NULL in where directly with simple syntax sometimes, but eq(col, null) works or isNull(col)
|
||||
// We need to check how to filter for null checkOutTime.
|
||||
@@ -662,59 +1216,135 @@ export class DrizzleDatabase implements IDatabase {
|
||||
};
|
||||
}
|
||||
|
||||
// Mappers
|
||||
private mapUser(row: any): User {
|
||||
// Mappers - using Record<string, unknown> for database rows since Drizzle types vary
|
||||
private mapUser(row: Record<string, unknown>): User {
|
||||
return {
|
||||
...row,
|
||||
gymId: (row as any).gymId ?? (row as any).gym_id ?? undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
id: String(row.id),
|
||||
email: String(row.email),
|
||||
firstName: String(row.firstName),
|
||||
lastName: String(row.lastName),
|
||||
password: String(row.password ?? ""),
|
||||
phone: row.phone ? String(row.phone) : undefined,
|
||||
role: String(row.role) as User["role"],
|
||||
gymId: row.gymId
|
||||
? String(row.gymId)
|
||||
: row.gym_id
|
||||
? String(row.gym_id)
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
updatedAt: new Date(row.updatedAt as number | Date),
|
||||
};
|
||||
}
|
||||
|
||||
private mapClient(row: any): Client {
|
||||
private mapClient(row: Record<string, unknown>): Client {
|
||||
return {
|
||||
...row,
|
||||
joinDate: new Date(row.joinDate),
|
||||
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessProfile(row: any): FitnessProfile {
|
||||
return {
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private mapAttendance(row: any): Attendance {
|
||||
return {
|
||||
...row,
|
||||
checkInTime: new Date(row.checkInTime),
|
||||
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
private mapRecommendation(row: any): Recommendation {
|
||||
return {
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessGoal(row: any): FitnessGoal {
|
||||
return {
|
||||
...row,
|
||||
startDate: new Date(row.startDate),
|
||||
targetDate: row.targetDate ? new Date(row.targetDate) : undefined,
|
||||
completedDate: row.completedDate
|
||||
? new Date(row.completedDate)
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
membershipType: String(row.membershipType) as Client["membershipType"],
|
||||
membershipStatus: String(
|
||||
row.membershipStatus,
|
||||
) as Client["membershipStatus"],
|
||||
joinDate: new Date(row.joinDate as number | Date),
|
||||
lastVisit: row.lastVisit
|
||||
? new Date(row.lastVisit as number | Date)
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
emergencyContact: row.emergencyContactName
|
||||
? {
|
||||
name: String(row.emergencyContactName),
|
||||
phone: String(row.emergencyContactPhone ?? ""),
|
||||
relationship: String(row.emergencyContactRelationship ?? ""),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessProfile(row: Record<string, unknown>): FitnessProfile {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
height: typeof row.height === "number" ? row.height : undefined,
|
||||
weight: typeof row.weight === "number" ? row.weight : undefined,
|
||||
age: typeof row.age === "number" ? row.age : undefined,
|
||||
gender: row.gender
|
||||
? (String(row.gender) as FitnessProfile["gender"])
|
||||
: undefined,
|
||||
fitnessGoals: row.goals ? [String(row.goals)] : undefined,
|
||||
medicalConditions: row.medicalConditions
|
||||
? String(row.medicalConditions)
|
||||
: undefined,
|
||||
allergies: row.dietaryRestrictions
|
||||
? String(row.dietaryRestrictions)
|
||||
: undefined,
|
||||
injuries: undefined,
|
||||
activityLevel: row.activityLevel
|
||||
? (String(row.activityLevel) as FitnessProfile["activityLevel"])
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
updatedAt: new Date(row.updatedAt as number | Date),
|
||||
};
|
||||
}
|
||||
|
||||
private mapAttendance(row: Record<string, unknown>): Attendance {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
type: String(row.type) as Attendance["type"],
|
||||
checkInTime: new Date(row.checkInTime as number | Date),
|
||||
checkOutTime: row.checkOutTime
|
||||
? new Date(row.checkOutTime as number | Date)
|
||||
: undefined,
|
||||
notes: row.notes ? String(row.notes) : undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
};
|
||||
}
|
||||
|
||||
private mapRecommendation(row: Record<string, unknown>): Recommendation {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
fitnessProfileId: String(row.fitnessProfileId),
|
||||
recommendationText: String(row.recommendationText),
|
||||
activityPlan: String(row.activityPlan),
|
||||
dietPlan: String(row.dietPlan),
|
||||
status: String(row.status) as Recommendation["status"],
|
||||
generatedAt: new Date(row.generatedAt as number | Date),
|
||||
approvedBy: row.approvedBy ? String(row.approvedBy) : undefined,
|
||||
approvedAt: row.approvedAt
|
||||
? new Date(row.approvedAt as number | Date)
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
updatedAt: new Date(row.updatedAt as number | Date),
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessGoal(row: Record<string, unknown>): FitnessGoal {
|
||||
return {
|
||||
id: String(row.id),
|
||||
userId: String(row.userId),
|
||||
fitnessProfileId: row.fitnessProfileId
|
||||
? String(row.fitnessProfileId)
|
||||
: undefined,
|
||||
goalType: String(row.goalType) as FitnessGoal["goalType"],
|
||||
title: String(row.title),
|
||||
description: row.description ? String(row.description) : undefined,
|
||||
targetValue:
|
||||
typeof row.targetValue === "number" ? row.targetValue : undefined,
|
||||
currentValue:
|
||||
typeof row.currentValue === "number" ? row.currentValue : undefined,
|
||||
unit: row.unit ? String(row.unit) : undefined,
|
||||
status: String(row.status) as FitnessGoal["status"],
|
||||
progress: typeof row.progress === "number" ? row.progress : 0,
|
||||
priority: String(row.priority) as FitnessGoal["priority"],
|
||||
startDate: new Date(row.startDate as number | Date),
|
||||
targetDate: row.targetDate
|
||||
? new Date(row.targetDate as number | Date)
|
||||
: undefined,
|
||||
completedDate: row.completedDate
|
||||
? new Date(row.completedDate as number | Date)
|
||||
: undefined,
|
||||
notes: row.notes ? String(row.notes) : undefined,
|
||||
createdAt: new Date(row.createdAt as number | Date),
|
||||
updatedAt: new Date(row.updatedAt as number | Date),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Recommendation,
|
||||
FitnessGoal,
|
||||
} from "@fitai/shared";
|
||||
import type { SortConfig, FilterCondition } from "../filtering";
|
||||
|
||||
// Database Entity Types
|
||||
export interface User extends SharedUser {
|
||||
@@ -33,11 +34,62 @@ export interface IDatabase {
|
||||
deleteUser(id: string): Promise<boolean>;
|
||||
migrateUserId(oldId: string, newId: string): Promise<void>;
|
||||
|
||||
// Optimized query methods (Phase 3 & 4 additions)
|
||||
getUsersWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
role?: string;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ users: User[]; total: number }>;
|
||||
|
||||
getUsersWithRelatedData(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
role?: string;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
users: Array<
|
||||
User & {
|
||||
client?: Client | null;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: Date | null;
|
||||
lastCheckInTime?: Date | null;
|
||||
checkInsThisWeek?: number;
|
||||
checkInsThisMonth?: number;
|
||||
}
|
||||
>;
|
||||
total?: number;
|
||||
}>;
|
||||
|
||||
getAttendanceStatsBatch(userIds: string[]): Promise<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
isCheckedIn: boolean;
|
||||
checkInTime: Date | null;
|
||||
lastCheckInTime: Date | null;
|
||||
checkInsThisWeek: number;
|
||||
checkInsThisMonth: number;
|
||||
}
|
||||
>
|
||||
>;
|
||||
|
||||
// Client operations
|
||||
createClient(client: Omit<Client, "id">): Promise<Client>;
|
||||
getClientById(id: string): Promise<Client | null>;
|
||||
getClientByUserId(userId: string): Promise<Client | null>;
|
||||
getAllClients(): Promise<Client[]>;
|
||||
getClientsWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ clients: Client[]; total: number }>;
|
||||
updateClient(id: string, updates: Partial<Client>): Promise<Client | null>;
|
||||
deleteClient(id: string): Promise<boolean>;
|
||||
|
||||
@@ -62,6 +114,13 @@ export interface IDatabase {
|
||||
checkOut(attendanceId: string): Promise<Attendance | null>;
|
||||
getAttendanceHistory(userId: string): Promise<Attendance[]>;
|
||||
getAllAttendance(): Promise<Attendance[]>;
|
||||
getAttendanceWithPagination(params: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sort?: SortConfig;
|
||||
filters?: FilterCondition[];
|
||||
search?: string;
|
||||
}): Promise<{ attendance: Attendance[]; total: number }>;
|
||||
getActiveCheckIn(userId: string): Promise<Attendance | null>;
|
||||
|
||||
// Recommendation operations
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Environment variable validation for admin app
|
||||
* Validates that all required environment variables are set
|
||||
*/
|
||||
|
||||
interface RequiredEnvVars {
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: string;
|
||||
CLERK_SECRET_KEY: string;
|
||||
DEEPSEEK_API_KEY: string;
|
||||
}
|
||||
|
||||
interface OptionalEnvVars {
|
||||
CLERK_WEBHOOK_SECRET?: string;
|
||||
DATABASE_PATH?: string;
|
||||
NODE_ENV?: string;
|
||||
}
|
||||
|
||||
type EnvVars = RequiredEnvVars & OptionalEnvVars;
|
||||
|
||||
/**
|
||||
* Validate required environment variables
|
||||
* Throws error if any required variable is missing
|
||||
*/
|
||||
export function validateEnv(): EnvVars {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Required variables
|
||||
const requiredVars = [
|
||||
"NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
|
||||
"CLERK_SECRET_KEY",
|
||||
"DEEPSEEK_API_KEY",
|
||||
] as const;
|
||||
|
||||
for (const varName of requiredVars) {
|
||||
if (!process.env[varName]) {
|
||||
errors.push(`Missing required environment variable: ${varName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessage = [
|
||||
"❌ Environment validation failed:",
|
||||
...errors,
|
||||
"",
|
||||
"Please check your .env file and ensure all required variables are set.",
|
||||
"See .env.example for the list of required variables.",
|
||||
].join("\n");
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return {
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
|
||||
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!,
|
||||
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY!,
|
||||
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY!,
|
||||
CLERK_WEBHOOK_SECRET: process.env.CLERK_WEBHOOK_SECRET,
|
||||
DATABASE_PATH: process.env.DATABASE_PATH || "./fitai.db",
|
||||
NODE_ENV: process.env.NODE_ENV || "development",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validated environment variables
|
||||
* Caches the result after first validation
|
||||
*/
|
||||
let cachedEnv: EnvVars | null = null;
|
||||
|
||||
export function getEnv(): EnvVars {
|
||||
if (!cachedEnv) {
|
||||
cachedEnv = validateEnv();
|
||||
}
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in development mode
|
||||
*/
|
||||
export function isDevelopment(): boolean {
|
||||
return getEnv().NODE_ENV === "development";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in production mode
|
||||
*/
|
||||
export function isProduction(): boolean {
|
||||
return getEnv().NODE_ENV === "production";
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Error handling utilities for type-safe error processing
|
||||
*/
|
||||
|
||||
/**
|
||||
* Clerk error structure
|
||||
*/
|
||||
interface ClerkError {
|
||||
errors?: Array<{
|
||||
message: string;
|
||||
code?: string;
|
||||
}>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if error is a Clerk error
|
||||
*/
|
||||
function isClerkError(error: unknown): error is ClerkError {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
("errors" in error || "message" in error)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error type
|
||||
* Handles Clerk errors, standard Error objects, and unknown types
|
||||
*
|
||||
* @param error - The caught error
|
||||
* @param fallback - Fallback message if no message can be extracted
|
||||
* @returns Error message string
|
||||
*/
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "An unknown error occurred",
|
||||
): string {
|
||||
// Handle Clerk errors with nested error array
|
||||
if (isClerkError(error)) {
|
||||
if (error.errors?.[0]?.message) {
|
||||
return error.errors[0].message;
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle standard Error objects
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
// Handle string errors
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
// Fallback for unknown types
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if metadata has a gymId
|
||||
*/
|
||||
interface MetadataWithGymId {
|
||||
gymId: string;
|
||||
}
|
||||
|
||||
function hasGymId(metadata: unknown): metadata is MetadataWithGymId {
|
||||
return (
|
||||
typeof metadata === "object" &&
|
||||
metadata !== null &&
|
||||
"gymId" in metadata &&
|
||||
typeof (metadata as Record<string, unknown>).gymId === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract gymId from Clerk public metadata
|
||||
*
|
||||
* @param user - Clerk user object
|
||||
* @returns gymId string or empty string if not found
|
||||
*/
|
||||
export function getGymIdFromUser(user: { publicMetadata?: unknown }): string {
|
||||
if (hasGymId(user.publicMetadata)) {
|
||||
return user.publicMetadata.gymId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { SQL, and, or, eq, gte, lte, like, inArray } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Sort direction for database queries
|
||||
*/
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
/**
|
||||
* Filter operator types
|
||||
*/
|
||||
export type FilterOperator =
|
||||
| "eq" // equals
|
||||
| "ne" // not equals
|
||||
| "gt" // greater than
|
||||
| "gte" // greater than or equal
|
||||
| "lt" // less than
|
||||
| "lte" // less than or equal
|
||||
| "like" // contains (case-insensitive)
|
||||
| "in" // in array
|
||||
| "between"; // between two values
|
||||
|
||||
/**
|
||||
* Single filter condition
|
||||
*/
|
||||
export interface FilterCondition {
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
value: string | number | boolean | string[] | number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter group with AND/OR logic
|
||||
*/
|
||||
export interface FilterGroup {
|
||||
conditions: FilterCondition[];
|
||||
logic?: "and" | "or";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort configuration
|
||||
*/
|
||||
export interface SortConfig {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse sort query parameter
|
||||
* Format: "field:asc" or "field:desc" or "-field" (shorthand for desc)
|
||||
*
|
||||
* @example
|
||||
* parseSortParam("name:asc") // { field: "name", direction: "asc" }
|
||||
* parseSortParam("-createdAt") // { field: "createdAt", direction: "desc" }
|
||||
*/
|
||||
export function parseSortParam(sort: string | null): SortConfig | null {
|
||||
if (!sort) return null;
|
||||
|
||||
// Handle shorthand: "-field" means descending
|
||||
if (sort.startsWith("-")) {
|
||||
return {
|
||||
field: sort.slice(1),
|
||||
direction: "desc",
|
||||
};
|
||||
}
|
||||
|
||||
// Handle explicit format: "field:asc" or "field:desc"
|
||||
const [field, direction] = sort.split(":");
|
||||
if (!field) return null;
|
||||
|
||||
return {
|
||||
field,
|
||||
direction: (direction as SortDirection) || "asc",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse filter query parameter
|
||||
* Format: "field:operator:value" or multiple separated by comma
|
||||
*
|
||||
* @example
|
||||
* parseFilterParam("role:eq:client") // Single filter
|
||||
* parseFilterParam("role:in:client,trainer") // Array filter
|
||||
* parseFilterParam("createdAt:gte:2024-01-01") // Date filter
|
||||
*/
|
||||
export function parseFilterParam(
|
||||
filter: string | string[] | null,
|
||||
): FilterCondition[] {
|
||||
if (!filter) return [];
|
||||
|
||||
const filters = Array.isArray(filter) ? filter : [filter];
|
||||
const conditions: FilterCondition[] = [];
|
||||
|
||||
for (const f of filters) {
|
||||
const parts = f.split(":");
|
||||
if (parts.length < 3) continue;
|
||||
|
||||
const [field, operator, ...valueParts] = parts;
|
||||
const value = valueParts.join(":"); // Rejoin in case value contains ":"
|
||||
|
||||
// Handle array values (comma-separated)
|
||||
if (operator === "in") {
|
||||
conditions.push({
|
||||
field,
|
||||
operator: operator as FilterOperator,
|
||||
value: value.split(","),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle between operator (two values separated by comma)
|
||||
if (operator === "between") {
|
||||
const [start, end] = value.split(",");
|
||||
conditions.push({
|
||||
field,
|
||||
operator: operator as FilterOperator,
|
||||
value: [start, end],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle single values
|
||||
conditions.push({
|
||||
field,
|
||||
operator: operator as FilterOperator,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
return conditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SQL WHERE conditions from filter conditions
|
||||
* This is a helper that works with Drizzle ORM
|
||||
*
|
||||
* @param conditions - Array of filter conditions
|
||||
* @param columnMap - Map of field names to Drizzle column objects
|
||||
* @param logic - AND or OR logic (default: AND)
|
||||
* @returns SQL condition or undefined
|
||||
*
|
||||
* @example
|
||||
* const where = buildWhereConditions(
|
||||
* [{ field: "role", operator: "eq", value: "client" }],
|
||||
* { role: users.role, email: users.email }
|
||||
* )
|
||||
*/
|
||||
export function buildWhereConditions(
|
||||
conditions: FilterCondition[],
|
||||
columnMap: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
logic: "and" | "or" = "and",
|
||||
): SQL | undefined {
|
||||
if (conditions.length === 0) return undefined;
|
||||
|
||||
const sqlConditions: SQL[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
const column = columnMap[condition.field];
|
||||
if (!column) continue; // Skip unknown fields
|
||||
|
||||
switch (condition.operator) {
|
||||
case "eq":
|
||||
sqlConditions.push(eq(column, condition.value as any));
|
||||
break;
|
||||
case "ne":
|
||||
// Note: Drizzle doesn't have ne(), use not(eq())
|
||||
// For now we'll skip this or implement custom
|
||||
break;
|
||||
case "gt":
|
||||
sqlConditions.push(gte(column, condition.value as any));
|
||||
break;
|
||||
case "gte":
|
||||
sqlConditions.push(gte(column, condition.value as any));
|
||||
break;
|
||||
case "lt":
|
||||
sqlConditions.push(lte(column, condition.value as any));
|
||||
break;
|
||||
case "lte":
|
||||
sqlConditions.push(lte(column, condition.value as any));
|
||||
break;
|
||||
case "like":
|
||||
sqlConditions.push(like(column, `%${condition.value}%`));
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(condition.value)) {
|
||||
sqlConditions.push(inArray(column, condition.value as any[]));
|
||||
}
|
||||
break;
|
||||
case "between":
|
||||
if (Array.isArray(condition.value) && condition.value.length === 2) {
|
||||
sqlConditions.push(
|
||||
and(
|
||||
gte(column, condition.value[0] as any),
|
||||
lte(column, condition.value[1] as any),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (sqlConditions.length === 0) return undefined;
|
||||
|
||||
return logic === "and" ? and(...sqlConditions) : or(...sqlConditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate sort field against allowed fields
|
||||
*/
|
||||
export function validateSortField(
|
||||
field: string,
|
||||
allowedFields: string[],
|
||||
): boolean {
|
||||
return allowedFields.includes(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate filter fields against allowed fields
|
||||
*/
|
||||
export function validateFilterFields(
|
||||
conditions: FilterCondition[],
|
||||
allowedFields: string[],
|
||||
): boolean {
|
||||
return conditions.every((c) => allowedFields.includes(c.field));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse search query parameter for full-text search
|
||||
* This is a simple implementation that searches across multiple fields
|
||||
*
|
||||
* @param search - Search query string
|
||||
* @param searchFields - Fields to search in
|
||||
* @param columnMap - Map of field names to Drizzle column objects
|
||||
* @returns SQL condition or undefined
|
||||
*/
|
||||
export function buildSearchCondition(
|
||||
search: string | null,
|
||||
searchFields: string[],
|
||||
columnMap: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
): SQL | undefined {
|
||||
if (!search || !search.trim()) return undefined;
|
||||
|
||||
const searchTerm = search.trim();
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
for (const field of searchFields) {
|
||||
const column = columnMap[field];
|
||||
if (!column) continue;
|
||||
conditions.push(like(column, `%${searchTerm}%`));
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? or(...conditions) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Centralized logging utility for FitAI Admin App
|
||||
* Uses Pino for structured logging with appropriate log levels
|
||||
*/
|
||||
|
||||
import pino from "pino";
|
||||
|
||||
/**
|
||||
* Create logger instance based on environment
|
||||
* - Development: Pretty-printed logs to console
|
||||
* - Production: JSON logs for aggregation/monitoring
|
||||
*/
|
||||
const logger = pino({
|
||||
level:
|
||||
process.env.LOG_LEVEL ||
|
||||
(process.env.NODE_ENV === "production" ? "info" : "debug"),
|
||||
formatters: {
|
||||
level: (label) => {
|
||||
return { level: label };
|
||||
},
|
||||
},
|
||||
...(process.env.NODE_ENV !== "production" && {
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: "HH:MM:ss Z",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Log levels:
|
||||
* - debug: Detailed information for debugging (only in dev)
|
||||
* - info: General informational messages
|
||||
* - warn: Warning messages for potentially harmful situations
|
||||
* - error: Error messages for failures that don't stop the app
|
||||
* - fatal: Critical errors that may cause the app to crash
|
||||
*/
|
||||
|
||||
export { logger };
|
||||
|
||||
/**
|
||||
* Convenience functions for common logging patterns
|
||||
*/
|
||||
|
||||
export const log = {
|
||||
/**
|
||||
* Log debug information (only in development)
|
||||
*/
|
||||
debug: (message: string, data?: Record<string, unknown>) => {
|
||||
logger.debug(data || {}, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log general information
|
||||
*/
|
||||
info: (message: string, data?: Record<string, unknown>) => {
|
||||
logger.info(data || {}, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log warnings
|
||||
*/
|
||||
warn: (message: string, data?: Record<string, unknown>) => {
|
||||
logger.warn(data || {}, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log errors with optional error object
|
||||
*/
|
||||
error: (message: string, error?: unknown, data?: Record<string, unknown>) => {
|
||||
const errorData: Record<string, unknown> = {
|
||||
...(data || {}),
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorData.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
} else if (error && typeof error === "object") {
|
||||
errorData.error = error;
|
||||
}
|
||||
|
||||
logger.error(errorData, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log fatal errors (should be rare)
|
||||
*/
|
||||
fatal: (message: string, error?: unknown, data?: Record<string, unknown>) => {
|
||||
const errorData: Record<string, unknown> = {
|
||||
...(data || {}),
|
||||
};
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorData.error = {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
};
|
||||
} else if (error && typeof error === "object") {
|
||||
errorData.error = error;
|
||||
}
|
||||
|
||||
logger.fatal(errorData, message);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log API requests (use in middleware or API routes)
|
||||
*/
|
||||
apiRequest: (
|
||||
method: string,
|
||||
path: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
logger.info({ method, path, ...(data || {}) }, "API request");
|
||||
},
|
||||
|
||||
/**
|
||||
* Log API responses
|
||||
*/
|
||||
apiResponse: (
|
||||
method: string,
|
||||
path: string,
|
||||
status: number,
|
||||
duration?: number,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
logger.info(
|
||||
{ method, path, status, duration, ...(data || {}) },
|
||||
"API response",
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Log database operations
|
||||
*/
|
||||
database: (
|
||||
operation: string,
|
||||
table?: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => {
|
||||
logger.debug({ operation, table, ...(data || {}) }, "Database operation");
|
||||
},
|
||||
|
||||
/**
|
||||
* Log authentication events
|
||||
*/
|
||||
auth: (event: string, userId?: string, data?: Record<string, unknown>) => {
|
||||
logger.info({ event, userId, ...(data || {}) }, "Authentication event");
|
||||
},
|
||||
};
|
||||
|
||||
export default log;
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Pagination utilities for API endpoints
|
||||
*/
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface PaginationMetadata {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
hasPrevPage: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: PaginationMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pagination parameters from URL search params
|
||||
*/
|
||||
export function parsePaginationParams(searchParams: URLSearchParams): {
|
||||
page: number;
|
||||
limit: number;
|
||||
sortBy?: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
} {
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, parseInt(searchParams.get("limit") || "20", 10)),
|
||||
);
|
||||
const sortBy = searchParams.get("sortBy") || undefined;
|
||||
const sortOrder = (searchParams.get("sortOrder") || "desc") as "asc" | "desc";
|
||||
|
||||
return { page, limit, sortBy, sortOrder };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create pagination metadata
|
||||
*/
|
||||
export function createPaginationMetadata(
|
||||
page: number,
|
||||
limit: number,
|
||||
total: number,
|
||||
): PaginationMetadata {
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPrevPage: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pagination to an array (for in-memory pagination)
|
||||
* Use this sparingly - prefer database-level pagination
|
||||
*/
|
||||
export function paginateArray<T>(
|
||||
items: T[],
|
||||
page: number,
|
||||
limit: number,
|
||||
): PaginatedResponse<T> {
|
||||
const offset = (page - 1) * limit;
|
||||
const paginatedItems = items.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
data: paginatedItems,
|
||||
pagination: createPaginationMetadata(page, limit, items.length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SQL LIMIT and OFFSET from page and limit
|
||||
*/
|
||||
export function getPaginationOffsets(page: number, limit: number) {
|
||||
const offset = (page - 1) * limit;
|
||||
return { limit, offset };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
interface RateLimitStore {
|
||||
[key: string]: {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
const store: RateLimitStore = {};
|
||||
|
||||
interface RateLimitConfig {
|
||||
interval: number; // Time window in milliseconds
|
||||
maxRequests: number; // Maximum requests in time window
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple in-memory rate limiter
|
||||
* For production, use Redis or a dedicated rate limiting service
|
||||
*
|
||||
* @param identifier - Unique identifier (e.g., IP address, user ID)
|
||||
* @param config - Rate limit configuration
|
||||
* @returns NextResponse error if rate limited, undefined if allowed
|
||||
*
|
||||
* @example
|
||||
* const rateLimitError = rateLimit(userId, { interval: 60000, maxRequests: 5 });
|
||||
* if (rateLimitError) return rateLimitError;
|
||||
*/
|
||||
export function rateLimit(
|
||||
identifier: string,
|
||||
config: RateLimitConfig,
|
||||
): NextResponse | undefined {
|
||||
const now = Date.now();
|
||||
const key = identifier;
|
||||
|
||||
// Initialize or reset if window expired
|
||||
if (!store[key] || now > store[key].resetTime) {
|
||||
store[key] = {
|
||||
count: 1,
|
||||
resetTime: now + config.interval,
|
||||
};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Increment count
|
||||
store[key].count++;
|
||||
|
||||
// Check if limit exceeded
|
||||
if (store[key].count > config.maxRequests) {
|
||||
const retryAfter = Math.ceil((store[key].resetTime - now) / 1000);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Too many requests",
|
||||
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
||||
retryAfter,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": retryAfter.toString(),
|
||||
"X-RateLimit-Limit": config.maxRequests.toString(),
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": store[key].resetTime.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit configurations for different endpoints
|
||||
*/
|
||||
export const RATE_LIMITS = {
|
||||
// Authentication endpoints - stricter limits
|
||||
login: { interval: 60000, maxRequests: 5 }, // 5 attempts per minute
|
||||
register: { interval: 3600000, maxRequests: 3 }, // 3 attempts per hour
|
||||
passwordReset: { interval: 3600000, maxRequests: 3 }, // 3 attempts per hour
|
||||
|
||||
// API endpoints - more lenient
|
||||
api: { interval: 60000, maxRequests: 100 }, // 100 requests per minute
|
||||
apiStrict: { interval: 60000, maxRequests: 10 }, // 10 requests per minute
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Get client identifier for rate limiting
|
||||
* Uses IP address as fallback if user is not authenticated
|
||||
*
|
||||
* @param request - NextRequest object
|
||||
* @param userId - Optional authenticated user ID
|
||||
* @returns Identifier for rate limiting
|
||||
*/
|
||||
export function getClientIdentifier(request: Request, userId?: string): string {
|
||||
if (userId) return `user:${userId}`;
|
||||
|
||||
// Try to get IP from headers (works with most reverse proxies)
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
const ip =
|
||||
forwarded?.split(",")[0] ?? request.headers.get("x-real-ip") ?? "unknown";
|
||||
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired rate limit entries (call periodically)
|
||||
*/
|
||||
export function cleanupRateLimitStore(): void {
|
||||
const now = Date.now();
|
||||
for (const key in store) {
|
||||
if (store[key].resetTime < now) {
|
||||
delete store[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup every 10 minutes
|
||||
if (typeof setInterval !== "undefined") {
|
||||
setInterval(cleanupRateLimitStore, 10 * 60 * 1000);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import type { IDatabase, User } from "./database/types";
|
||||
import log from "./logger";
|
||||
|
||||
export async function ensureUserSynced(
|
||||
userId: string,
|
||||
@@ -8,7 +9,7 @@ export async function ensureUserSynced(
|
||||
const existingUser = await db.getUserById(userId);
|
||||
if (existingUser) return existingUser;
|
||||
|
||||
console.log("User not found in DB by ID, checking Clerk:", userId);
|
||||
log.debug("User not found in DB by ID, checking Clerk", { userId });
|
||||
const clerkUser = await currentUser();
|
||||
|
||||
if (!clerkUser || clerkUser.id !== userId) {
|
||||
@@ -24,7 +25,7 @@ export async function ensureUserSynced(
|
||||
// Check if user exists by email (e.g. seeded user)
|
||||
const existingByEmail = await db.getUserByEmail(email);
|
||||
if (existingByEmail) {
|
||||
console.log("User found by email but ID mismatch. Migrating ID...", {
|
||||
log.debug("User found by email but ID mismatch, migrating ID", {
|
||||
oldId: existingByEmail.id,
|
||||
newId: userId,
|
||||
});
|
||||
@@ -50,7 +51,7 @@ export async function ensureUserSynced(
|
||||
return db.getUserById(userId);
|
||||
}
|
||||
|
||||
console.log("Creating new user from Clerk data:", userId);
|
||||
log.debug("Creating new user from Clerk data", { userId, email });
|
||||
const user = await db.createUser({
|
||||
id: userId,
|
||||
email,
|
||||
@@ -59,11 +60,11 @@ export async function ensureUserSynced(
|
||||
password: "", // Managed by Clerk
|
||||
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
||||
gymId: (clerkUser.publicMetadata.gymId as string | undefined) || undefined,
|
||||
role: ((): any => {
|
||||
role: (() => {
|
||||
const r = clerkUser.publicMetadata.role as string | undefined;
|
||||
return r &&
|
||||
["superAdmin", "admin", "trainer", "client", "generalUser"].includes(r)
|
||||
? r
|
||||
const validRoles = ["superAdmin", "admin", "trainer", "client"] as const;
|
||||
return r && validRoles.includes(r as (typeof validRoles)[number])
|
||||
? (r as (typeof validRoles)[number])
|
||||
: "client";
|
||||
})(),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user