phase 1-6 implemented

This commit is contained in:
dimitar committed 2026-03-10 04:14:03 +01:00
1 parent 22c274bb83
commit 2c4927478a
73 files changed
+9702 -2354

No files matched your search

@@ -1,31 +1,32 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { ensureUserSynced } from '@/lib/sync-user'
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
export async function POST(req: Request) {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase()
const db = await getDatabase();
// Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db)
// Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db);
// Check if already checked in
const activeCheckIn = await db.getActiveCheckIn(userId)
if (activeCheckIn) {
return new NextResponse('Already checked in', { status: 400 })
}
const body = await req.json()
const { type = 'gym', notes } = body
const attendance = await db.checkIn(userId, type, notes)
return NextResponse.json(attendance)
} catch (error) {
console.error('Check-in error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
// Check if already checked in
const activeCheckIn = await db.getActiveCheckIn(userId);
if (activeCheckIn) {
return new NextResponse("Already checked in", { status: 400 });
}
const body = await req.json();
const { type = "gym", notes } = body;
const attendance = await db.checkIn(userId, type, notes);
return NextResponse.json(attendance);
} catch (error) {
log.error("Failed to check in", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -1,23 +1,24 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
export async function POST(req: Request) {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase()
const db = await getDatabase();
const activeCheckIn = await db.getActiveCheckIn(userId)
if (!activeCheckIn) {
return new NextResponse('No active check-in found', { status: 404 })
}
const attendance = await db.checkOut(activeCheckIn.id)
return NextResponse.json(attendance)
} catch (error) {
console.error('Check-out error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
const activeCheckIn = await db.getActiveCheckIn(userId);
if (!activeCheckIn) {
return new NextResponse("No active check-in found", { status: 404 });
}
const attendance = await db.checkOut(activeCheckIn.id);
return NextResponse.json(attendance);
} catch (error) {
log.error("Failed to check out", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -1,31 +1,33 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { ensureUserSynced } from '@/lib/sync-user'
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
export async function GET(req: Request) {
console.log('GET /api/attendance/history called')
console.log('Headers:', Object.fromEntries(req.headers.entries()))
log.apiRequest("GET", "/api/attendance/history", {
headers: Object.fromEntries(req.headers.entries()),
});
try {
const authResult = await auth()
const { userId } = authResult
console.log('Auth result:', JSON.stringify(authResult, null, 2))
try {
const authResult = await auth();
const { userId } = authResult;
log.debug("Auth result received", { authResult });
if (!userId) {
console.log('No userId found in auth result')
return new NextResponse('Unauthorized', { status: 401 })
}
const db = await getDatabase()
// Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db)
const history = await db.getAttendanceHistory(userId)
return NextResponse.json(history)
} catch (error) {
console.error('History error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
if (!userId) {
log.warn("No userId found in auth result");
return new NextResponse("Unauthorized", { status: 401 });
}
const db = await getDatabase();
// Ensure user exists in DB (sync from Clerk if needed)
await ensureUserSynced(userId, db);
const history = await db.getAttendanceHistory(userId);
return NextResponse.json(history);
} catch (error) {
log.error("Failed to fetch attendance history", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
+26 -25
View File
@@ -1,48 +1,49 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { getDatabase } from '../../../../lib/database/index'
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { getDatabase } from "../../../../lib/database/index";
import log from "@/lib/logger";
export async function POST(request: NextRequest) {
try {
const db = await getDatabase()
const { email, password } = await request.json()
const db = await getDatabase();
const { email, password } = await request.json();
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
)
{ error: "Email and password are required" },
{ status: 400 },
);
}
const user = await db.getUserByEmail(email)
const user = await db.getUserByEmail(email);
if (!user) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
{ error: "Invalid credentials" },
{ status: 401 },
);
}
const isValidPassword = await bcrypt.compare(password, user.password)
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
)
{ error: "Invalid credentials" },
{ status: 401 },
);
}
const { password: _, ...userWithoutPassword } = user
const { password: _, ...userWithoutPassword } = user;
return NextResponse.json({
message: 'Login successful',
user: userWithoutPassword
})
message: "Login successful",
user: userWithoutPassword,
});
} catch (error) {
console.error('Login error:', error)
log.error("Login failed", error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
{ error: "Internal server error" },
{ status: 500 },
);
}
}
}
+42 -38
View File
@@ -1,29 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { getDatabase } from '../../../../lib/database/index'
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { getDatabase } from "../../../../lib/database/index";
import log from "@/lib/logger";
export async function POST(request: NextRequest) {
try {
const db = await getDatabase()
const { email, password, firstName, lastName, phone } = await request.json()
const db = await getDatabase();
const { email, password, firstName, lastName, phone } =
await request.json();
if (!email || !password || !firstName || !lastName) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
)
{ error: "Missing required fields" },
{ status: 400 },
);
}
const existingUser = await db.getUserByEmail(email)
const existingUser = await db.getUserByEmail(email);
if (existingUser) {
return NextResponse.json(
{ error: 'User already exists' },
{ status: 409 }
)
{ error: "User already exists" },
{ status: 409 },
);
}
const hashedPassword = await bcrypt.hash(password, 10)
const hashedPassword = await bcrypt.hash(password, 10);
const newUser = await db.createUser({
email,
@@ -31,45 +33,47 @@ export async function POST(request: NextRequest) {
lastName,
password: hashedPassword,
phone,
role: 'client'
})
role: "client",
});
const newClient = await db.createClient({
userId: newUser.id,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
})
membershipType: "basic",
membershipStatus: "active",
joinDate: new Date(),
});
const { password: _, ...userWithoutPassword } = newUser
const { password: _, ...userWithoutPassword } = newUser;
return NextResponse.json(
{
message: 'User registered successfully',
user: { ...userWithoutPassword, client: newClient }
{
message: "User registered successfully",
user: { ...userWithoutPassword, client: newClient },
},
{ status: 201 }
)
{ status: 201 },
);
} catch (error) {
console.error('Registration error:', error)
log.error("User registration failed", error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function GET() {
try {
const db = await getDatabase()
const allUsers = await db.getAllUsers()
const usersWithoutPassword = allUsers.map(({ password: _, ...user }) => user)
return NextResponse.json({ users: usersWithoutPassword })
const db = await getDatabase();
const allUsers = await db.getAllUsers();
const usersWithoutPassword = allUsers.map(
({ password: _, ...user }) => user,
);
return NextResponse.json({ users: usersWithoutPassword });
} catch (error) {
console.error('Get users error:', error)
log.error("Failed to get users", error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
{ error: "Internal server error" },
{ status: 500 },
);
}
}
}
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
// Helper to add CORS headers
function corsHeaders() {
@@ -52,7 +53,7 @@ export async function GET(
return NextResponse.json(goal, { headers: corsHeaders() });
} catch (error) {
console.error("Error fetching fitness goal:", error);
log.error("Failed to fetch fitness goal", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500, headers: corsHeaders() },
@@ -103,7 +104,7 @@ export async function PUT(
return NextResponse.json(updatedGoal, { headers: corsHeaders() });
} catch (error) {
console.error("Error updating fitness goal:", error);
log.error("Failed to update fitness goal", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500, headers: corsHeaders() },
@@ -154,7 +155,7 @@ export async function DELETE(
);
}
} catch (error) {
console.error("Error deleting fitness goal:", error);
log.error("Failed to delete fitness goal", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500, headers: corsHeaders() },
+13 -15
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { randomBytes } from "crypto";
import log from "@/lib/logger";
// Helper to add CORS headers
function corsHeaders() {
@@ -33,18 +34,16 @@ export async function GET(req: NextRequest) {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
console.error("[Fitness Goals API] Authentication failed");
console.error(
"[Fitness Goals API] Headers:",
Object.fromEntries(req.headers.entries()),
);
log.error("Fitness goals GET - authentication failed", undefined, {
headers: Object.fromEntries(req.headers.entries()),
});
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401, headers: corsHeaders() },
);
}
console.log("[Fitness Goals API] Authenticated user:", userId);
log.debug("Fitness goals GET - authenticated user", { userId });
const { searchParams } = new URL(req.url);
const targetUserId = searchParams.get("userId") || userId;
@@ -58,12 +57,13 @@ export async function GET(req: NextRequest) {
status && status !== "all" ? status : undefined,
);
console.log(
`[Fitness Goals API] Found ${goals.length} goals for user ${targetUserId}`,
);
log.debug("Fitness goals retrieved", {
count: goals.length,
userId: targetUserId,
});
return NextResponse.json(goals, { headers: corsHeaders() });
} catch (error) {
console.error("[Fitness Goals API] Error:", error);
log.error("Failed to get fitness goals", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500, headers: corsHeaders() },
@@ -77,7 +77,7 @@ export async function POST(req: NextRequest) {
const userId = await getAuthenticatedUserId(req);
if (!userId) {
console.error("[Fitness Goals API] Authentication failed for POST");
log.error("Fitness goals POST - authentication failed");
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401, headers: corsHeaders() },
@@ -127,12 +127,10 @@ export async function POST(req: NextRequest) {
notes: notes || undefined,
});
console.log(
`[Fitness Goals API] Created goal ${goal.id} for user ${userId}`,
);
log.info("Fitness goal created", { goalId: goal.id, userId });
return NextResponse.json(goal, { status: 201, headers: corsHeaders() });
} catch (error) {
console.error("[Fitness Goals API] Error creating goal:", error);
log.error("Failed to create fitness goal", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500, headers: corsHeaders() },
+13 -14
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import Database from "better-sqlite3";
import { randomBytes } from "crypto";
import log from "@/lib/logger";
const db = new Database("./data/fitai.db");
@@ -15,16 +16,14 @@ export async function GET(request: NextRequest) {
}
const profile = db
.prepare(
`SELECT * FROM fitness_profiles WHERE userId = ?`
)
.prepare(`SELECT * FROM fitness_profiles WHERE userId = ?`)
.get(userId);
if (profile) {
const p = profile as any;
// Parse JSON fields
try {
p.fitnessGoals = JSON.parse(p.fitnessGoals || '[]');
p.fitnessGoals = JSON.parse(p.fitnessGoals || "[]");
} catch (e) {
p.fitnessGoals = [];
}
@@ -32,10 +31,10 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ profile: profile || null });
} catch (error) {
console.error("Error fetching fitness profile:", error);
log.error("Failed to fetch fitness profile", error);
return NextResponse.json(
{ error: "Failed to fetch fitness profile" },
{ status: 500 }
{ status: 500 },
);
}
}
@@ -61,7 +60,7 @@ export async function POST(request: NextRequest) {
allergies,
injuries,
exerciseHabits,
dietHabits
dietHabits,
} = body;
// Check if profile exists
@@ -88,7 +87,7 @@ export async function POST(request: NextRequest) {
exerciseHabits = ?,
dietHabits = ?,
updatedAt = ?
WHERE userId = ?`
WHERE userId = ?`,
).run(
height || null,
weight || null,
@@ -102,7 +101,7 @@ export async function POST(request: NextRequest) {
exerciseHabits || null,
dietHabits || null,
now,
userId
userId,
);
return NextResponse.json({
@@ -115,7 +114,7 @@ export async function POST(request: NextRequest) {
`INSERT INTO fitness_profiles
(userId, height, weight, age, gender, fitnessGoals, activityLevel,
medicalConditions, allergies, injuries, exerciseHabits, dietHabits, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
userId,
height || null,
@@ -130,7 +129,7 @@ export async function POST(request: NextRequest) {
exerciseHabits || null,
dietHabits || null,
now,
now
now,
);
return NextResponse.json(
@@ -138,14 +137,14 @@ export async function POST(request: NextRequest) {
message: "Fitness profile created successfully",
userId,
},
{ status: 201 }
{ status: 201 },
);
}
} catch (error) {
console.error("Error saving fitness profile:", error);
log.error("Failed to save fitness profile", error);
return NextResponse.json(
{ error: "Failed to save fitness profile" },
{ status: 500 }
{ status: 500 },
);
}
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
async function ensureGymsTable() {
await db.run(sql`
@@ -29,7 +30,7 @@ export async function GET() {
return NextResponse.json(rows);
} catch (error) {
console.error("GET /gyms error:", error);
log.error("Failed to get gyms", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
@@ -160,7 +161,7 @@ export async function POST(req: Request) {
const created = await db.get(sql`SELECT * FROM gyms WHERE id = ${id}`);
return NextResponse.json(created, { status: 201 });
} catch (error) {
console.error("POST /gyms error:", error);
log.error("Failed to create gym", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
+47 -42
View File
@@ -1,77 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { getDatabase } from '../../../../lib/database/index'
import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "../../../../lib/database/index";
import log from "@/lib/logger";
export async function POST(request: NextRequest) {
try {
const db = await getDatabase()
const profileData = await request.json()
const db = await getDatabase();
const profileData = await request.json();
if (!profileData.userId || !profileData.height || !profileData.weight || !profileData.age) {
if (
!profileData.userId ||
!profileData.height ||
!profileData.weight ||
!profileData.age
) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
)
{ error: "Missing required fields" },
{ status: 400 },
);
}
// Check if user exists
const user = await db.getUserById(profileData.userId)
const user = await db.getUserById(profileData.userId);
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Check if profile already exists
const existingProfile = await db.getFitnessProfileByUserId(profileData.userId)
let profile
const existingProfile = await db.getFitnessProfileByUserId(
profileData.userId,
);
let profile;
if (existingProfile) {
profile = await db.updateFitnessProfile(profileData.userId, profileData)
profile = await db.updateFitnessProfile(profileData.userId, profileData);
} else {
profile = await db.createFitnessProfile(profileData)
profile = await db.createFitnessProfile(profileData);
}
return NextResponse.json(
{
message: 'Fitness profile saved successfully',
profile
{
message: "Fitness profile saved successfully",
profile,
},
{ status: 201 }
)
{ status: 201 },
);
} catch (error) {
console.error('Fitness profile error:', error)
log.error("Failed to save fitness profile", error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
{ error: "Internal server error" },
{ status: 500 },
);
}
}
export async function GET(request: NextRequest) {
try {
const db = await getDatabase()
const { searchParams } = new URL(request.url)
const userId = searchParams.get('userId')
const db = await getDatabase();
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
if (userId) {
const profile = await db.getFitnessProfileByUserId(userId)
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
return NextResponse.json(
{ error: 'Profile not found' },
{ status: 404 }
)
{ error: "Profile not found" },
{ status: 404 },
);
}
return NextResponse.json({ profile })
return NextResponse.json({ profile });
}
const profiles = await db.getAllFitnessProfiles()
return NextResponse.json({ profiles })
const profiles = await db.getAllFitnessProfiles();
return NextResponse.json({ profiles });
} catch (error) {
console.error('Get fitness profiles error:', error)
log.error("Failed to get fitness profiles", error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
{ error: "Internal server error" },
{ status: 500 },
);
}
}
}
@@ -2,190 +2,220 @@ import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { buildAIContext } from "@/lib/ai/ai-context";
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
import log from "@/lib/logger";
export async function POST(req: Request) {
try {
const { userId, useExternalModel } = await req.json();
try {
const { userId, useExternalModel } = await req.json();
if (!userId) {
return NextResponse.json({ error: "User ID is required" }, { status: 400 });
}
const db = await getDatabase();
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
return NextResponse.json(
{ error: "Fitness profile not found for this user" },
{ status: 404 }
);
}
// Build AI context with goals and recommendations
let prompt: string;
try {
const context = await buildAIContext(userId);
prompt = buildEnhancedPrompt(context);
console.log('Using enhanced AI context with goals and history');
} catch (error) {
// Fallback to basic prompt if context building fails
console.warn('Failed to build AI context, using basic prompt:', error);
prompt = buildBasicPrompt(profile);
}
let parsedResponse;
if (useExternalModel) {
// Use DeepSeek AI
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
if (!deepseekApiKey) {
return NextResponse.json(
{ error: "DeepSeek API key not configured" },
{ status: 500 }
);
}
console.log("Using DeepSeek AI model...");
const deepseekResponse = await fetch("https://api.deepseek.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${deepseekApiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [
{
role: "system",
content: "You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks."
},
{
role: "user",
content: prompt
}
],
temperature: 0.7,
max_tokens: 1000,
}),
});
if (!deepseekResponse.ok) {
const errorText = await deepseekResponse.text();
console.error("DeepSeek API error:", errorText);
return NextResponse.json(
{ error: "Failed to generate recommendation from DeepSeek AI" },
{ status: 500 }
);
}
const deepseekData = await deepseekResponse.json();
console.log("Raw DeepSeek Response:", deepseekData);
try {
const content = deepseekData.choices[0].message.content;
let cleanResponse = content.trim();
// Remove markdown code blocks if present
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
} else if (cleanResponse.startsWith("```")) {
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
}
// Find the first '{' and last '}' to extract the JSON object
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
}
parsedResponse = JSON.parse(cleanResponse);
} catch (e) {
console.error("Failed to parse DeepSeek response:", deepseekData);
return NextResponse.json(
{ error: "Invalid response format from DeepSeek AI" },
{ status: 500 }
);
}
} else {
// Use local Ollama
console.log("Using local Ollama model...");
const ollamaResponse = await fetch("http://localhost:11434/api/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gemma3:latest",
prompt: prompt,
stream: false,
format: "json",
}),
});
if (!ollamaResponse.ok) {
console.error("Ollama API error:", await ollamaResponse.text());
return NextResponse.json(
{ error: "Failed to generate recommendation from Ollama" },
{ status: 500 }
);
}
const aiData = await ollamaResponse.json();
console.log("Raw Ollama Response:", aiData.response);
try {
let cleanResponse = aiData.response.trim();
// Remove markdown code blocks if present
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse.replace(/^```json\s*/, "").replace(/\s*```$/, "");
} else if (cleanResponse.startsWith("```")) {
cleanResponse = cleanResponse.replace(/^```\s*/, "").replace(/\s*```$/, "");
}
// Find the first '{' and last '}' to extract the JSON object
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
}
parsedResponse = JSON.parse(cleanResponse);
} catch (e) {
console.error("Failed to parse Ollama response:", aiData.response);
return NextResponse.json(
{ error: "Invalid response format from Ollama" },
{ status: 500 }
);
}
}
// Save to database
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.id,
type: 'ai_plan',
recommendationText: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan,
dietPlan: parsedResponse.dietPlan,
status: 'pending'
});
return NextResponse.json(recommendation);
} catch (error) {
console.error("Error generating recommendation:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
if (!userId) {
return NextResponse.json(
{ error: "User ID is required" },
{ status: 400 },
);
}
const db = await getDatabase();
// Fetch fitness profile
const profile = await db.getFitnessProfileByUserId(userId);
if (!profile) {
return NextResponse.json(
{ error: "Fitness profile not found for this user" },
{ status: 404 },
);
}
// Build AI context with goals and recommendations
let prompt: string;
try {
const context = await buildAIContext(userId);
prompt = buildEnhancedPrompt(context);
log.debug("Using enhanced AI context with goals and history");
} catch (error) {
// Fallback to basic prompt if context building fails
log.warn("Failed to build AI context, using basic prompt", {
error: error instanceof Error ? error.message : String(error),
});
prompt = buildBasicPrompt(profile);
}
let parsedResponse;
if (useExternalModel) {
// Use DeepSeek AI
const deepseekApiKey = process.env.DEEPSEEK_API_KEY;
if (!deepseekApiKey) {
return NextResponse.json(
{ error: "DeepSeek API key not configured" },
{ status: 500 },
);
}
log.debug("Using DeepSeek AI model", { userId });
const deepseekResponse = await fetch(
"https://api.deepseek.com/v1/chat/completions",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${deepseekApiKey}`,
},
body: JSON.stringify({
model: "deepseek-chat",
messages: [
{
role: "system",
content:
"You are a professional fitness trainer and nutritionist. Always respond with valid JSON only, no markdown or code blocks.",
},
{
role: "user",
content: prompt,
},
],
temperature: 0.7,
max_tokens: 1000,
}),
},
);
if (!deepseekResponse.ok) {
const errorText = await deepseekResponse.text();
log.error("DeepSeek API request failed", new Error(errorText), {
status: deepseekResponse.status,
});
return NextResponse.json(
{ error: "Failed to generate recommendation from DeepSeek AI" },
{ status: 500 },
);
}
const deepseekData = await deepseekResponse.json();
log.debug("Received DeepSeek response", { deepseekData });
try {
const content = deepseekData.choices[0].message.content;
let cleanResponse = content.trim();
// Remove markdown code blocks if present
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse
.replace(/^```json\s*/, "")
.replace(/\s*```$/, "");
} else if (cleanResponse.startsWith("```")) {
cleanResponse = cleanResponse
.replace(/^```\s*/, "")
.replace(/\s*```$/, "");
}
// Find the first '{' and last '}' to extract the JSON object
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
}
parsedResponse = JSON.parse(cleanResponse);
} catch (e) {
log.error("Failed to parse DeepSeek response", e, {
response: deepseekData,
});
return NextResponse.json(
{ error: "Invalid response format from DeepSeek AI" },
{ status: 500 },
);
}
} else {
// Use local Ollama
log.debug("Using local Ollama model", { userId });
const ollamaResponse = await fetch(
"http://localhost:11434/api/generate",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gemma3:latest",
prompt: prompt,
stream: false,
format: "json",
}),
},
);
if (!ollamaResponse.ok) {
const errorText = await ollamaResponse.text();
log.error("Ollama API request failed", new Error(errorText), {
status: ollamaResponse.status,
});
return NextResponse.json(
{ error: "Failed to generate recommendation from Ollama" },
{ status: 500 },
);
}
const aiData = await ollamaResponse.json();
log.debug("Received Ollama response", { response: aiData.response });
try {
let cleanResponse = aiData.response.trim();
// Remove markdown code blocks if present
if (cleanResponse.startsWith("```json")) {
cleanResponse = cleanResponse
.replace(/^```json\s*/, "")
.replace(/\s*```$/, "");
} else if (cleanResponse.startsWith("```")) {
cleanResponse = cleanResponse
.replace(/^```\s*/, "")
.replace(/\s*```$/, "");
}
// Find the first '{' and last '}' to extract the JSON object
const firstBrace = cleanResponse.indexOf("{");
const lastBrace = cleanResponse.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1) {
cleanResponse = cleanResponse.substring(firstBrace, lastBrace + 1);
}
parsedResponse = JSON.parse(cleanResponse);
} catch (e) {
log.error("Failed to parse Ollama response", e, {
response: aiData.response,
});
return NextResponse.json(
{ error: "Invalid response format from Ollama" },
{ status: 500 },
);
}
}
// Save to database
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId: profile.id,
type: "ai_plan",
recommendationText: parsedResponse.recommendationText,
activityPlan: parsedResponse.activityPlan,
dietPlan: parsedResponse.dietPlan,
status: "pending",
});
return NextResponse.json(recommendation);
} catch (error) {
log.error("Failed to generate recommendation", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+145 -124
View File
@@ -1,136 +1,157 @@
import { NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { getDatabase } from '@/lib/database'
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import log from "@/lib/logger";
export async function GET(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const { searchParams } = new URL(request.url)
const targetUserId = searchParams.get('userId')
const db = await getDatabase()
// If no userId provided, check if staff and return all recommendations
if (!targetUserId) {
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (!isStaff) {
return new NextResponse('User ID is required', { status: 400 })
}
const recommendations = await db.getAllRecommendations()
return NextResponse.json({ recommendations })
}
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (currentUserId !== targetUserId) {
if (!isStaff) {
return new NextResponse('Forbidden', { status: 403 })
}
}
let recommendations = await db.getRecommendationsByUserId(targetUserId)
// Non-staff users should only see approved recommendations
if (!isStaff) {
recommendations = recommendations.filter((rec: any) => rec.status === 'approved')
}
return NextResponse.json(recommendations)
} catch (error) {
console.error('Error fetching recommendations:', error)
return new NextResponse('Internal Server Error', { status: 500 })
try {
const { userId: currentUserId } = await auth();
if (!currentUserId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const { searchParams } = new URL(request.url);
const targetUserId = searchParams.get("userId");
const db = await getDatabase();
// If no userId provided, check if staff and return all recommendations
if (!targetUserId) {
const currentUser = await db.getUserById(currentUserId);
const isStaff =
currentUser?.role === "admin" ||
currentUser?.role === "superAdmin" ||
currentUser?.role === "trainer";
if (!isStaff) {
return new NextResponse("User ID is required", { status: 400 });
}
const recommendations = await db.getAllRecommendations();
return NextResponse.json({ recommendations });
}
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
const currentUser = await db.getUserById(currentUserId);
const isStaff =
currentUser?.role === "admin" ||
currentUser?.role === "superAdmin" ||
currentUser?.role === "trainer";
if (currentUserId !== targetUserId) {
if (!isStaff) {
return new NextResponse("Forbidden", { status: 403 });
}
}
let recommendations = await db.getRecommendationsByUserId(targetUserId);
// Non-staff users should only see approved recommendations
if (!isStaff) {
recommendations = recommendations.filter(
(rec: any) => rec.status === "approved",
);
}
return NextResponse.json(recommendations);
} catch (error) {
console.error("Error fetching recommendations:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const db = await getDatabase()
const currentUser = await db.getUserById(currentUserId)
const isStaff = currentUser?.role === 'admin' || currentUser?.role === 'superAdmin' || currentUser?.role === 'trainer'
if (!isStaff) {
return new NextResponse('Forbidden', { status: 403 })
}
const body = await request.json()
const { userId, fitnessProfileId, recommendationText, activityPlan, dietPlan, status, type, content } = body
// Handle AI Plan (Legacy/Specific)
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId,
type: 'ai_plan',
recommendationText: recommendationText,
activityPlan,
dietPlan,
status: status || 'pending'
})
return NextResponse.json(recommendation)
}
// Handle User Goal (Generic)
if (type && content) {
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
type,
recommendationText: content,
status: status || 'pending'
})
return NextResponse.json(recommendation)
}
return NextResponse.json('Missing required fields', { status: 400 })
} catch (error) {
console.error('Error creating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 })
try {
const { userId: currentUserId } = await auth();
if (!currentUserId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const db = await getDatabase();
const currentUser = await db.getUserById(currentUserId);
const isStaff =
currentUser?.role === "admin" ||
currentUser?.role === "superAdmin" ||
currentUser?.role === "trainer";
if (!isStaff) {
return new NextResponse("Forbidden", { status: 403 });
}
const body = await request.json();
const {
userId,
fitnessProfileId,
recommendationText,
activityPlan,
dietPlan,
status,
type,
content,
} = body;
// Handle AI Plan (Legacy/Specific)
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
fitnessProfileId,
type: "ai_plan",
recommendationText: recommendationText,
activityPlan,
dietPlan,
status: status || "pending",
});
return NextResponse.json(recommendation);
}
// Handle User Goal (Generic)
if (type && content) {
const recommendation = await db.createRecommendation({
id: crypto.randomUUID(),
userId,
type,
recommendationText: content,
status: status || "pending",
});
return NextResponse.json(recommendation);
}
return NextResponse.json("Missing required fields", { status: 400 });
} catch (error) {
log.error("Failed to create recommendation", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
export async function PUT(request: Request) {
try {
const { userId: currentUserId } = await auth()
if (!currentUserId) {
return new NextResponse('Unauthorized', { status: 401 })
}
const body = await request.json()
const { id, status, recommendationText, activityPlan, dietPlan, content } = body
if (!id) {
return new NextResponse('Recommendation ID is required', { status: 400 })
}
const db = await getDatabase()
const updated = await db.updateRecommendation(id, {
...(status && { status }),
...(recommendationText && { recommendationText }),
...(content && { recommendationText: content }),
...(activityPlan && { activityPlan }),
...(dietPlan && { dietPlan })
})
return NextResponse.json(updated)
} catch (error) {
console.error('Error updating recommendation:', error)
return new NextResponse('Internal Server Error', { status: 500 })
try {
const { userId: currentUserId } = await auth();
if (!currentUserId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const body = await request.json();
const { id, status, recommendationText, activityPlan, dietPlan, content } =
body;
if (!id) {
return new NextResponse("Recommendation ID is required", { status: 400 });
}
const db = await getDatabase();
const updated = await db.updateRecommendation(id, {
...(status && { status }),
...(recommendationText && { recommendationText }),
...(content && { recommendationText: content }),
...(activityPlan && { activityPlan }),
...(dietPlan && { dietPlan }),
});
return NextResponse.json(updated);
} catch (error) {
log.error("Failed to update recommendation", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
+13 -15
View File
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { db, users as usersTable, eq, sql } from "@fitai/database";
import log from "@/lib/logger";
/**
* PATCH /api/users/gym
@@ -12,7 +13,7 @@ import { db, users as usersTable, eq, sql } from "@fitai/database";
export async function PATCH(req: Request) {
try {
const { userId } = await auth();
console.log("PATCH /api/users/gym auth userId:", userId);
log.debug("Updating user gym assignment", { userId });
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const body = await req.json().catch(() => null);
@@ -24,32 +25,32 @@ export async function PATCH(req: Request) {
}
const gymId = body.gymId === null ? null : String(body.gymId);
console.log("PATCH /api/users/gym parsed gymId from body:", gymId);
log.debug("Parsed gym ID from request body", { gymId });
// Ensure user exists
console.log("PATCH /api/users/gym fetching user by id:", userId);
log.debug("Fetching user by ID", { userId });
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId))
.get();
console.log("PATCH /api/users/gym fetched user:", user);
log.debug("User fetched", { user: user ? { id: user.id } : null });
if (!user) return new NextResponse("User not found", { status: 404 });
// Validate gym when provided
if (gymId) {
console.log("PATCH /api/users/gym validating gym:", gymId);
log.debug("Validating gym", { gymId });
const rows = await db.all(
sql`SELECT status FROM gyms WHERE id = ${gymId} LIMIT 1`,
);
console.log("PATCH /api/users/gym validation query result rows:", rows);
log.debug("Gym validation query result", { rowCount: rows?.length });
const gym = rows?.[0] as { status?: string } | undefined;
if (!gym) {
console.log("PATCH /api/users/gym validation: gym not found");
log.warn("Gym not found during validation", { gymId });
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
}
if (gym.status !== "active") {
console.log("PATCH /api/users/gym validation: gym not active", gym);
log.warn("Gym is not active", { gymId, status: gym.status });
return NextResponse.json(
{ error: "Gym is not active" },
{ status: 400 },
@@ -58,24 +59,21 @@ export async function PATCH(req: Request) {
}
// Update user's gym selection
console.log("PATCH /api/users/gym updating user gym_id:", {
userId,
gymId,
});
log.debug("Updating user gym assignment in database", { userId, gymId });
await db.run(
sql`UPDATE users SET gym_id = ${gymId ?? null}, updated_at = ${Date.now()} WHERE id = ${userId}`,
);
console.log("PATCH /api/users/gym update completed");
log.debug("User gym assignment updated");
const updated = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId))
.get();
console.log("PATCH /api/users/gym returning updated user:", updated);
log.debug("Returning updated user", { userId });
return NextResponse.json(updated);
} catch (error) {
console.error("PATCH /users/gym error:", error);
log.error("Failed to update user gym assignment", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
+44 -48
View File
@@ -3,6 +3,7 @@ import { getDatabase } from "../../../lib/database/index";
import bcrypt from "bcryptjs";
import { auth, clerkClient } from "@clerk/nextjs/server";
import { db as rawDb, sql } from "@fitai/database";
import log from "@/lib/logger";
export async function GET(request: NextRequest) {
try {
@@ -31,28 +32,25 @@ export async function GET(request: NextRequest) {
(g.name as string) || (g.id as string),
]),
);
console.log(
"GET /api/users: total users fetched from DB:",
Array.isArray(users) ? users.length : 0,
);
log.debug("Fetched users from database", {
totalUsers: Array.isArray(users) ? users.length : 0,
});
if (role) {
users = users.filter((user) => user.role === role);
}
console.log(
"GET /api/users: role filter:",
log.debug("Applied role filter", {
role,
"users after filter:",
Array.isArray(users) ? users.length : 0,
"sample:",
users && users[0]
? {
id: users[0].id,
role: users[0].role,
gymId: (users as any)[0].gymId,
}
: null,
);
usersAfterFilter: Array.isArray(users) ? users.length : 0,
sample:
users && users[0]
? {
id: users[0].id,
role: users[0].role,
gymId: (users as any)[0].gymId,
}
: null,
});
const usersWithClients = await Promise.all(
users.map(async (user) => {
@@ -116,21 +114,20 @@ export async function GET(request: NextRequest) {
}),
);
console.log(
"GET /api/users: responding users count:",
Array.isArray(usersWithClients) ? usersWithClients.length : 0,
"sample:",
usersWithClients && usersWithClients[0]
? {
id: usersWithClients[0].id,
role: usersWithClients[0].role,
gymId: (usersWithClients as any)[0].gymId,
}
: null,
);
log.debug("Prepared response with user details", {
usersCount: Array.isArray(usersWithClients) ? usersWithClients.length : 0,
sample:
usersWithClients && usersWithClients[0]
? {
id: usersWithClients[0].id,
role: usersWithClients[0].role,
gymId: (usersWithClients as any)[0].gymId,
}
: null,
});
return NextResponse.json({ users: usersWithClients });
} catch (error) {
console.error("Get users error:", error);
log.error("Failed to get users", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
@@ -218,7 +215,11 @@ export async function POST(request: NextRequest) {
ignoreExisting: true, // Don't fail if invite exists
});
} catch (clerkError: any) {
console.error("Clerk invitation error:", clerkError);
log.error("Clerk invitation failed", clerkError, {
email,
role,
errorCode: clerkError.errors?.[0]?.code,
});
// If user already exists in Clerk, we might want to handle it.
// But for now, let's proceed to create local record if invite sent or if they exist.
if (clerkError.errors?.[0]?.code === "form_identifier_exists") {
@@ -263,7 +264,7 @@ export async function POST(request: NextRequest) {
{ status: 201 },
);
} catch (error) {
console.error("Create user error:", error);
log.error("Failed to create user", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
@@ -276,7 +277,7 @@ export async function PUT(request: NextRequest) {
const db = await getDatabase();
const body = await request.json();
const { id, email, firstName, lastName, role, phone, gymId } = body;
console.log("PUT /api/users received body:", {
log.debug("Updating user", {
id,
email,
firstName,
@@ -346,7 +347,7 @@ export async function PUT(request: NextRequest) {
try {
const client = await clerkClient();
const publicMetadata: Record<string, unknown> = {};
console.log("PUT /api/users preparing Clerk metadata update:", {
log.debug("Preparing Clerk metadata update", {
targetUserId: id,
role,
gymId,
@@ -360,23 +361,20 @@ export async function PUT(request: NextRequest) {
}
if (Object.keys(publicMetadata).length > 0) {
console.log(
"PUT /api/users calling Clerk updateUser with metadata:",
publicMetadata,
);
log.debug("Updating Clerk user metadata", { publicMetadata });
const clerkResult = await client.users.updateUser(id, {
publicMetadata,
});
console.log("PUT /api/users Clerk updateUser result:", {
log.debug("Clerk user updated successfully", {
id: clerkResult.id,
role: clerkResult.publicMetadata?.role,
gymId: clerkResult.publicMetadata?.gymId,
});
} else {
console.log("PUT /api/users no Clerk metadata changes requested");
log.debug("No Clerk metadata changes requested");
}
} catch (clerkErr: any) {
console.error("Clerk metadata update error:", clerkErr);
log.error("Clerk metadata update failed", clerkErr, { userId: id });
return NextResponse.json(
{ error: "Failed to update role/gym in identity provider" },
{ status: 500 },
@@ -384,9 +382,7 @@ export async function PUT(request: NextRequest) {
}
// Update local DB for immediate UI feedback (webhook will also sync)
console.log(
"PUT /api/users raw SQL updating local DB user gym_id and fields",
);
log.debug("Updating local database user");
await rawDb.run(
sql`UPDATE users
SET email = ${email ?? existingUser.email},
@@ -402,7 +398,7 @@ export async function PUT(request: NextRequest) {
const updatedRow = await rawDb.get(
sql`SELECT id, email, first_name, last_name, role, phone, gym_id, created_at, updated_at FROM users WHERE id = ${id}`,
);
console.log("PUT /api/users raw DB row after update:", updatedRow);
log.debug("User updated in database", { updatedRow });
const updatedUser = {
...existingUser,
@@ -418,11 +414,11 @@ export async function PUT(request: NextRequest) {
? gymId
: existingUser.gymId,
};
console.log("PUT /api/users responding with updated user:", updatedUser);
log.debug("Sending updated user response", { updatedUser });
return NextResponse.json({ user: updatedUser });
} catch (error) {
console.error("Update user error:", error);
log.error("Failed to update user", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
@@ -457,7 +453,7 @@ export async function DELETE(request: NextRequest) {
);
}
} catch (error) {
console.error("Delete user error:", error);
log.error("Failed to delete user(s)", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
+30 -12
View File
@@ -4,6 +4,7 @@ import { WebhookEvent } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import Database from "better-sqlite3";
import path from "path";
import log from "@/lib/logger";
export async function POST(req: Request) {
// Get the webhook secret from environment variables
@@ -46,13 +47,16 @@ export async function POST(req: Request) {
"svix-signature": svix_signature,
}) as WebhookEvent;
} catch (err) {
console.error("Error verifying webhook:", err);
log.error("Webhook verification failed", err);
return NextResponse.json({ error: "Verification failed" }, { status: 400 });
}
// Handle the webhook
const eventType = evt.type;
console.log(`Received webhook with ID ${evt.data.id} and type ${eventType}`);
log.info("Received webhook event", {
userId: evt.data.id,
eventType,
});
try {
// Connect to database directly for webhook operations
@@ -70,7 +74,13 @@ export async function POST(req: Request) {
);
if (!primaryEmail?.email_address) {
console.error("No primary email found for user:", id);
log.error(
"No primary email found for user (user.created)",
undefined,
{
userId: id,
},
);
db.close();
return NextResponse.json(
{ error: "No primary email" },
@@ -161,9 +171,11 @@ export async function POST(req: Request) {
}
}
console.log(
`✅ User ${id} created in database (role=${role}, gymId=${gymId ?? "null"})`,
);
log.info("User created in database", {
userId: id,
role,
gymId: gymId ?? null,
});
db.close();
break;
}
@@ -178,7 +190,13 @@ export async function POST(req: Request) {
);
if (!primaryEmail?.email_address) {
console.error("No primary email found for user:", id);
log.error(
"No primary email found for user (user.updated)",
undefined,
{
userId: id,
},
);
db.close();
return NextResponse.json(
{ error: "No primary email" },
@@ -236,7 +254,7 @@ export async function POST(req: Request) {
}
}
console.log(`✅ User ${id} updated in database`);
log.info("User updated in database", { userId: id });
db.close();
break;
}
@@ -245,7 +263,7 @@ export async function POST(req: Request) {
const { id } = evt.data;
if (!id) {
console.error("No user ID provided for deletion");
log.error("No user ID provided for deletion");
db.close();
return NextResponse.json({ error: "No user ID" }, { status: 400 });
}
@@ -254,13 +272,13 @@ export async function POST(req: Request) {
const stmt = db.prepare("DELETE FROM users WHERE id = ?");
stmt.run(id);
console.log(`✅ User ${id} deleted from database`);
log.info("User deleted from database", { userId: id });
db.close();
break;
}
default:
console.log(`Unhandled webhook event type: ${eventType}`);
log.debug("Unhandled webhook event type", { eventType });
db.close();
}
@@ -269,7 +287,7 @@ export async function POST(req: Request) {
{ status: 200 },
);
} catch (error) {
console.error("Error processing webhook:", error);
log.error("Webhook processing failed", error);
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
}
}
+261 -234
View File
@@ -2,259 +2,286 @@
import { useEffect, useState } from "react";
import { useUser } from "@clerk/nextjs";
import log from "@/lib/logger";
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
id: string;
firstName: string;
lastName: string;
email: string;
}
interface Recommendation {
id: string;
userId: string;
content: string;
activityPlan: string;
dietPlan: string;
status: string;
createdAt: Date;
id: string;
userId: string;
content: string;
recommendationText: string;
activityPlan: string;
dietPlan: string;
status: string;
createdAt: Date;
}
export default function RecommendationsPage() {
const { user } = useUser();
const [users, setUsers] = useState<User[]>([]);
const [pendingRecommendations, setPendingRecommendations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [useExternalModel, setUseExternalModel] = useState(false);
const { user } = useUser();
const [users, setUsers] = useState<User[]>([]);
const [pendingRecommendations, setPendingRecommendations] = useState<
Recommendation[]
>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [useExternalModel, setUseExternalModel] = useState(false);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
// Fetch users
const usersRes = await fetch("/api/users");
const usersData = await usersRes.json();
setUsers(usersData.users || []);
const fetchData = async () => {
try {
// Fetch users
const usersRes = await fetch("/api/users");
const usersData = await usersRes.json();
setUsers(usersData.users || []);
// Fetch pending recommendations
const recsRes = await fetch("/api/recommendations");
const recsData = await recsRes.json();
const allRecs = recsData.recommendations || [];
setPendingRecommendations(allRecs.filter((r: any) => r.status === 'pending'));
} catch (error) {
console.error("Error fetching data:", error);
} finally {
setLoading(false);
}
};
// Fetch pending recommendations
const recsRes = await fetch("/api/recommendations");
const recsData = await recsRes.json();
const allRecs = recsData.recommendations || [];
setPendingRecommendations(
allRecs.filter((r: Recommendation) => r.status === "pending"),
);
} catch (error) {
log.error("Failed to fetch data", error);
} finally {
setLoading(false);
}
};
const handleGenerate = async (userId: string) => {
setGenerating(userId);
try {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, useExternalModel }),
});
const handleGenerate = async (userId: string) => {
setGenerating(userId);
try {
const res = await fetch("/api/recommendations/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, useExternalModel }),
});
if (!res.ok) {
const error = await res.json();
alert(`Error: ${error.error}`);
} else {
alert("Recommendation generated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
console.error(error);
alert("Failed to generate recommendation.");
} finally {
setGenerating(null);
}
};
if (!res.ok) {
const error = await res.json();
alert(`Error: ${error.error}`);
} else {
alert("Recommendation generated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
log.error("Failed to generate recommendation", error);
alert("Failed to generate recommendation.");
} finally {
setGenerating(null);
}
};
const handleApprove = async (recommendationId: string, status: "approved" | "rejected") => {
try {
const res = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId,
status,
approvedBy: user?.id || "admin",
}),
});
const handleApprove = async (
recommendationId: string,
status: "approved" | "rejected",
) => {
try {
const res = await fetch("/api/recommendations/approve", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
recommendationId,
status,
approvedBy: user?.id || "admin",
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update status: ${errorData.error || 'Unknown error'}`);
} else {
fetchData(); // Refresh data
}
} catch (error) {
console.error(error);
alert("Error processing request");
}
};
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update status: ${errorData.error || "Unknown error"}`);
} else {
fetchData(); // Refresh data
}
} catch (error) {
log.error("Failed to approve recommendation", error);
alert("Error processing request");
}
};
const handleEdit = async (rec: Recommendation) => {
const newContent = prompt("Edit Advice:", rec.content);
const newActivityPlan = prompt("Edit Activity Plan:", rec.activityPlan);
const newDietPlan = prompt("Edit Diet Plan:", rec.dietPlan);
const handleEdit = async (rec: Recommendation) => {
const newContent = prompt("Edit Advice:", rec.content);
const newActivityPlan = prompt("Edit Activity Plan:", rec.activityPlan);
const newDietPlan = prompt("Edit Diet Plan:", rec.dietPlan);
if (newContent === null || newActivityPlan === null || newDietPlan === null) {
// User cancelled one of the prompts
return;
}
try {
const res = await fetch("/api/recommendations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: rec.id,
content: newContent,
activityPlan: newActivityPlan,
dietPlan: newDietPlan,
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(`Failed to update recommendation: ${errorData.error || 'Unknown error'}`);
} else {
alert("Recommendation updated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
console.error("Error updating recommendation:", error);
alert("Failed to update recommendation.");
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="text-xl">Loading...</div>
</div>
);
if (
newContent === null ||
newActivityPlan === null ||
newDietPlan === null
) {
// User cancelled one of the prompts
return;
}
try {
const res = await fetch("/api/recommendations", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: rec.id,
content: newContent,
activityPlan: newActivityPlan,
dietPlan: newDietPlan,
}),
});
if (!res.ok) {
const errorData = await res.json();
alert(
`Failed to update recommendation: ${errorData.error || "Unknown error"}`,
);
} else {
alert("Recommendation updated successfully!");
fetchData(); // Refresh data
}
} catch (error) {
log.error("Failed to update recommendation", error);
alert("Failed to update recommendation.");
}
};
if (loading) {
return (
<div className="container mx-auto py-10 px-4">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">AI Recommendations</h1>
{/* Model Selection Toggle */}
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-lg shadow">
<span className="text-sm font-medium text-gray-700">
{useExternalModel ? "DeepSeek AI" : "Local Ollama"}
</span>
<button
onClick={() => setUseExternalModel(!useExternalModel)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${useExternalModel ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${useExternalModel ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className="text-xs text-gray-500">
{useExternalModel ? "External" : "Local"}
</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Generate Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Generate Recommendations</h2>
<div className="bg-white shadow rounded-lg p-6">
<p className="mb-4 text-gray-600">
Select a user to generate a new daily recommendation.
</p>
<ul className="space-y-4">
{users.map((user) => (
<li key={user.id} className="flex items-center justify-between border-b pb-2">
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<button
onClick={() => handleGenerate(user.id)}
disabled={generating === user.id}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{generating === user.id ? "Generating..." : "Generate"}
</button>
</li>
))}
{users.length === 0 && (
<p className="text-gray-500 italic">No users found.</p>
)}
</ul>
</div>
</div>
{/* Pending Approvals Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Pending Approvals</h2>
<div className="bg-white shadow rounded-lg p-6">
{pendingRecommendations.length === 0 ? (
<p className="text-gray-500 italic">No pending recommendations.</p>
) : (
<ul className="space-y-6">
{pendingRecommendations.map((rec) => (
<li key={rec.id} className="border rounded p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold">For: User {rec.userId}</h3>
<span className="text-xs text-gray-500">
{new Date(rec.createdAt).toLocaleDateString()}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div>
<span className="font-semibold">Advice:</span> {rec.recommendationText}
</div>
<div>
<span className="font-semibold">Activity:</span> {rec.activityPlan}
</div>
<div>
<span className="font-semibold">Diet:</span> {rec.dietPlan}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEdit(rec)}
className="flex-1 bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
Edit
</button>
<button
onClick={() => handleApprove(rec.id, "approved")}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
>
Approve
</button>
<button
onClick={() => handleApprove(rec.id, "rejected")}
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700"
>
Reject
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center justify-center h-screen">
<div className="text-xl">Loading...</div>
</div>
);
}
return (
<div className="container mx-auto py-10 px-4">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">AI Recommendations</h1>
{/* Model Selection Toggle */}
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-lg shadow">
<span className="text-sm font-medium text-gray-700">
{useExternalModel ? "DeepSeek AI" : "Local Ollama"}
</span>
<button
onClick={() => setUseExternalModel(!useExternalModel)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
useExternalModel ? "bg-blue-600" : "bg-gray-300"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
useExternalModel ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className="text-xs text-gray-500">
{useExternalModel ? "External" : "Local"}
</span>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Generate Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">
Generate Recommendations
</h2>
<div className="bg-white shadow rounded-lg p-6">
<p className="mb-4 text-gray-600">
Select a user to generate a new daily recommendation.
</p>
<ul className="space-y-4">
{users.map((user) => (
<li
key={user.id}
className="flex items-center justify-between border-b pb-2"
>
<div>
<p className="font-medium">
{user.firstName} {user.lastName}
</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<button
onClick={() => handleGenerate(user.id)}
disabled={generating === user.id}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{generating === user.id ? "Generating..." : "Generate"}
</button>
</li>
))}
{users.length === 0 && (
<p className="text-gray-500 italic">No users found.</p>
)}
</ul>
</div>
</div>
{/* Pending Approvals Section */}
<div>
<h2 className="text-2xl font-semibold mb-4">Pending Approvals</h2>
<div className="bg-white shadow rounded-lg p-6">
{pendingRecommendations.length === 0 ? (
<p className="text-gray-500 italic">
No pending recommendations.
</p>
) : (
<ul className="space-y-6">
{pendingRecommendations.map((rec) => (
<li key={rec.id} className="border rounded p-4">
<div className="flex justify-between items-start mb-2">
<h3 className="font-bold">For: User {rec.userId}</h3>
<span className="text-xs text-gray-500">
{new Date(rec.createdAt).toLocaleDateString()}
</span>
</div>
<div className="space-y-2 text-sm mb-4">
<div>
<span className="font-semibold">Advice:</span>{" "}
{rec.recommendationText}
</div>
<div>
<span className="font-semibold">Activity:</span>{" "}
{rec.activityPlan}
</div>
<div>
<span className="font-semibold">Diet:</span>{" "}
{rec.dietPlan}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEdit(rec)}
className="flex-1 bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
>
Edit
</button>
<button
onClick={() => handleApprove(rec.id, "approved")}
className="flex-1 bg-green-600 text-white py-2 rounded hover:bg-green-700"
>
Approve
</button>
<button
onClick={() => handleApprove(rec.id, "rejected")}
className="flex-1 bg-red-600 text-white py-2 rounded hover:bg-red-700"
>
Reject
</button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
</div>
</div>
);
}
+7 -6
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
import log from "@/lib/logger";
interface Backup {
name: string;
@@ -56,7 +57,7 @@ export default function SettingsPage() {
const response = await axios.get("/api/admin/backups");
setBackups(response.data);
} catch (error) {
console.error("Failed to fetch backups:", error);
log.error("Failed to fetch backups", error);
} finally {
setLoading(false);
}
@@ -69,7 +70,7 @@ export default function SettingsPage() {
const res = await axios.get("/api/gyms");
setGyms(Array.isArray(res.data) ? res.data : []);
} catch (error) {
console.error("Failed to fetch gyms:", error);
log.error("Failed to fetch gyms", error);
setGymMessage({ type: "error", text: "Failed to load gyms" });
} finally {
setGymsLoading(false);
@@ -89,7 +90,7 @@ export default function SettingsPage() {
await fetchBackups();
setMessage({ type: "success", text: "Backup created successfully" });
} catch (error) {
console.error("Failed to create backup:", error);
log.error("Failed to create backup", error);
setMessage({ type: "error", text: "Failed to create backup" });
} finally {
setCreatingBackup(false);
@@ -112,7 +113,7 @@ export default function SettingsPage() {
setMessage({ type: "success", text: "Database restored successfully" });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) {
console.error("Failed to restore backup:", error);
log.error("Failed to restore backup", error);
setMessage({ type: "error", text: "Failed to restore backup" });
} finally {
setRestoring(null);
@@ -144,7 +145,7 @@ export default function SettingsPage() {
text: gymId ? "Gym selected successfully" : "Proceeding without gym",
});
} catch (error) {
console.error("Failed to set gym:", error);
log.error("Failed to set gym", error);
setGymMessage({ type: "error", text: "Failed to set gym" });
}
};
@@ -247,7 +248,7 @@ export default function SettingsPage() {
setGymLocation("");
fetchGyms();
} catch (error) {
console.error("Failed to create gym:", error);
log.error("Failed to create gym", error);
setGymMessage({
type: "error",
text: "Failed to create gym",
+121 -90
View File
@@ -1,104 +1,135 @@
import { getDatabase } from "@/lib/database";
import { Recommendations } from "@/components/users/Recommendations";
import { notFound } from "next/navigation";
interface PageProps {
params: Promise<{
id: string;
}>;
params: Promise<{
id: string;
}>;
}
export default async function UserProfilePage({ params }: PageProps) {
const { id } = await params;
const db = await getDatabase();
const user = await db.getUserById(id);
const { id } = await params;
const db = await getDatabase();
const user = await db.getUserById(id);
if (!user) {
notFound();
}
if (!user) {
notFound();
}
const client = await db.getClientByUserId(user.id);
const fitnessProfile = await db.getFitnessProfileByUserId(user.id);
const client = await db.getClientByUserId(user.id);
const fitnessProfile = await db.getFitnessProfileByUserId(user.id);
return (
<div className="space-y-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">
{user.firstName} {user.lastName}
</h1>
<span className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium capitalize">
{user.role}
</span>
</div>
return (
<div className="space-y-8">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">
{user.firstName} {user.lastName}
</h1>
<span className="px-3 py-1 bg-gray-100 rounded-full text-sm font-medium capitalize">
{user.role}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Contact Information</h2>
<div className="space-y-2">
<p><span className="font-medium">Email:</span> {user.email}</p>
<p><span className="font-medium">Phone:</span> {user.phone || "N/A"}</p>
<p><span className="font-medium">Joined:</span> {user.createdAt.toLocaleDateString()}</p>
</div>
</div>
{/* Client Info */}
{client && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Membership Details</h2>
<div className="space-y-2">
<p><span className="font-medium">Type:</span> {client.membershipType}</p>
<p><span className="font-medium">Status:</span> {client.membershipStatus}</p>
<p><span className="font-medium">Member Since:</span> {client.joinDate.toLocaleDateString()}</p>
<p><span className="font-medium">Last Visit:</span> {client.lastVisit?.toLocaleDateString() || "Never"}</p>
</div>
</div>
)}
</div>
{/* Fitness Profile */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Fitness Profile</h2>
{fitnessProfile ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 className="font-medium mb-2 text-gray-700">Physical Stats</h3>
<p>Height: {fitnessProfile.height} cm</p>
<p>Weight: {fitnessProfile.weight} kg</p>
<p>Age: {fitnessProfile.age}</p>
<p>Gender: {fitnessProfile.gender}</p>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Health & Habits</h3>
<p>Activity Level: {fitnessProfile.activityLevel.replace('_', ' ')}</p>
<p>Diet: {fitnessProfile.dietHabits || "N/A"}</p>
<p>Exercise: {fitnessProfile.exerciseHabits || "N/A"}</p>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Medical</h3>
<p>Conditions: {fitnessProfile.medicalConditions || "None"}</p>
<p>Allergies: {fitnessProfile.allergies || "None"}</p>
<p>Injuries: {fitnessProfile.injuries || "None"}</p>
</div>
<div className="col-span-full mt-4">
<h3 className="font-medium mb-2 text-gray-700">Goals</h3>
<div className="flex flex-wrap gap-2">
{fitnessProfile.fitnessGoals.map((goal, i) => (
<span key={i} className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm">
{goal.replace('_', ' ')}
</span>
))}
</div>
</div>
</div>
) : (
<p className="text-gray-500 italic">No fitness profile created yet.</p>
)}
</div>
{/* Recommendations Component */}
<Recommendations userId={user.id} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Contact Information</h2>
<div className="space-y-2">
<p>
<span className="font-medium">Email:</span> {user.email}
</p>
<p>
<span className="font-medium">Phone:</span> {user.phone || "N/A"}
</p>
<p>
<span className="font-medium">Joined:</span>{" "}
{user.createdAt.toLocaleDateString()}
</p>
</div>
</div>
);
{/* Client Info */}
{client && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Membership Details</h2>
<div className="space-y-2">
<p>
<span className="font-medium">Type:</span>{" "}
{client.membershipType}
</p>
<p>
<span className="font-medium">Status:</span>{" "}
{client.membershipStatus}
</p>
<p>
<span className="font-medium">Member Since:</span>{" "}
{client.joinDate.toLocaleDateString()}
</p>
<p>
<span className="font-medium">Last Visit:</span>{" "}
{client.lastVisit?.toLocaleDateString() || "Never"}
</p>
</div>
</div>
)}
</div>
{/* Fitness Profile */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Fitness Profile</h2>
{fitnessProfile ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 className="font-medium mb-2 text-gray-700">Physical Stats</h3>
<p>Height: {fitnessProfile.height} cm</p>
<p>Weight: {fitnessProfile.weight} kg</p>
<p>Age: {fitnessProfile.age}</p>
<p>Gender: {fitnessProfile.gender}</p>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">
Health & Habits
</h3>
<p>
Activity Level:{" "}
{fitnessProfile.activityLevel?.replace("_", " ") || "N/A"}
</p>
</div>
<div>
<h3 className="font-medium mb-2 text-gray-700">Medical</h3>
<p>Conditions: {fitnessProfile.medicalConditions || "None"}</p>
<p>Allergies: {fitnessProfile.allergies || "None"}</p>
<p>Injuries: {fitnessProfile.injuries || "None"}</p>
</div>
<div className="col-span-full mt-4">
<h3 className="font-medium mb-2 text-gray-700">Goals</h3>
<div className="flex flex-wrap gap-2">
{fitnessProfile.fitnessGoals &&
fitnessProfile.fitnessGoals.length > 0 ? (
fitnessProfile.fitnessGoals.map((goal, i) => (
<span
key={i}
className="px-2 py-1 bg-blue-50 text-blue-700 rounded text-sm"
>
{goal.replace("_", " ")}
</span>
))
) : (
<span className="text-gray-500 italic">No goals set</span>
)}
</div>
</div>
</div>
) : (
<p className="text-gray-500 italic">
No fitness profile created yet.
</p>
)}
</div>
{/* Recommendations Component */}
<Recommendations userId={user.id} />
</div>
);
}
@@ -3,172 +3,203 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import log from "@/lib/logger";
interface Recommendation {
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
recommendationText: string;
activityPlan?: string;
dietPlan?: string;
status: "pending" | "completed" | "approved" | "rejected";
createdAt: string;
id: string;
userId: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
recommendationText: string;
activityPlan?: string;
dietPlan?: string;
status: "pending" | "completed" | "approved" | "rejected";
createdAt: string;
}
interface RecommendationsProps {
userId: string;
userId: string;
}
export function Recommendations({ userId }: RecommendationsProps) {
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [newRec, setNewRec] = useState<{
type: "short_term" | "medium_term" | "long_term";
content: string;
}>({ type: "short_term", content: "" });
const [recommendations, setRecommendations] = useState<Recommendation[]>([]);
const [loading, setLoading] = useState(true);
const [newRec, setNewRec] = useState<{
type: "short_term" | "medium_term" | "long_term";
content: string;
}>({ type: "short_term", content: "" });
useEffect(() => {
useEffect(() => {
fetchRecommendations();
}, [userId]);
const fetchRecommendations = async () => {
setLoading(true);
try {
const response = await fetch(`/api/recommendations?userId=${userId}`);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
}
} catch (error) {
log.error("Failed to fetch recommendations", error);
} finally {
setLoading(false);
}
};
const handleAddRecommendation = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId,
type: newRec.type,
content: newRec.content,
}),
});
if (response.ok) {
setNewRec({ ...newRec, content: "" });
fetchRecommendations();
}, [userId]);
} else {
alert("Failed to add recommendation");
}
} catch (error) {
log.error("Failed to add recommendation", error);
}
};
const fetchRecommendations = async () => {
setLoading(true);
try {
const response = await fetch(`/api/recommendations?userId=${userId}`);
if (response.ok) {
const data = await response.json();
setRecommendations(data);
}
} catch (error) {
console.error("Failed to fetch recommendations:", error);
} finally {
setLoading(false);
}
};
const groupedRecs = {
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
short_term: recommendations.filter((r) => r.type === "short_term"),
medium_term: recommendations.filter((r) => r.type === "medium_term"),
long_term: recommendations.filter((r) => r.type === "long_term"),
};
const handleAddRecommendation = async (e: React.FormEvent) => {
e.preventDefault();
try {
const response = await fetch("/api/recommendations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId,
type: newRec.type,
content: newRec.content,
}),
});
if (response.ok) {
setNewRec({ ...newRec, content: "" });
fetchRecommendations();
} else {
alert("Failed to add recommendation");
}
} catch (error) {
console.error(error);
}
};
const groupedRecs = {
ai_plan: recommendations.filter((r) => r.type === "ai_plan"),
short_term: recommendations.filter((r) => r.type === "short_term"),
medium_term: recommendations.filter((r) => r.type === "medium_term"),
long_term: recommendations.filter((r) => r.type === "long_term"),
};
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term" | "ai_plan",
items: Recommendation[]
) => (
<div className="mb-6">
<h4 className="font-semibold text-lg mb-3 capitalize">{title}</h4>
<div className="space-y-2">
{items.length === 0 && (
<p className="text-gray-500 text-sm italic">No recommendations yet.</p>
)}
{items.map((rec) => (
<div
key={rec.id}
className={`p-3 rounded border flex justify-between items-start ${rec.status === "completed" || rec.status === "approved"
? "bg-green-50 border-green-200"
: "bg-white border-gray-200"
}`}
>
<div className="w-full">
<p className="text-gray-700">{rec.recommendationText}</p>
{rec.type === 'ai_plan' && (
<div className="mt-2 text-xs text-gray-600 space-y-1">
{rec.activityPlan && <p><span className="font-semibold">Activity:</span> {rec.activityPlan}</p>}
{rec.dietPlan && <p><span className="font-semibold">Diet:</span> {rec.dietPlan}</p>}
</div>
)}
<p className="text-xs text-gray-400 mt-2">
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
<span
className={
rec.status === "completed" || rec.status === "approved"
? "text-green-600 font-medium"
: "text-yellow-600"
}
>
{rec.status === "completed" ? "Completed" : rec.status === "approved" ? "Approved" : "Pending"}
</span>
</p>
</div>
</div>
))}
</div>
{type !== 'ai_plan' && (
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
<input
type="hidden"
value={type}
onChange={() => setNewRec({ ...newRec, type: type as any })}
/>
{newRec.type === type && (
<>
<input
type="text"
placeholder={`Add ${title.toLowerCase()}...`}
className="flex-1 border rounded px-3 py-1 text-sm"
value={newRec.content}
onChange={(e) =>
setNewRec({ ...newRec, content: e.target.value })
}
required
/>
<Button type="submit" variant="secondary">
Add
</Button>
</>
)}
{newRec.type !== type && (
<Button type="button" variant="secondary" onClick={() => setNewRec({ type: type as any, content: "" })} className="text-xs text-gray-500">
+ Add New
</Button>
)}
</form>
)}
</div>
);
if (loading) return <div>Loading recommendations...</div>;
return (
<Card>
<CardHeader>
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
{renderSection("Short Term Goals", "short_term", groupedRecs.short_term)}
{renderSection("Medium Term Goals", "medium_term", groupedRecs.medium_term)}
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
const renderSection = (
title: string,
type: "short_term" | "medium_term" | "long_term" | "ai_plan",
items: Recommendation[],
) => (
<div className="mb-6">
<h4 className="font-semibold text-lg mb-3 capitalize">{title}</h4>
<div className="space-y-2">
{items.length === 0 && (
<p className="text-gray-500 text-sm italic">
No recommendations yet.
</p>
)}
{items.map((rec) => (
<div
key={rec.id}
className={`p-3 rounded border flex justify-between items-start ${
rec.status === "completed" || rec.status === "approved"
? "bg-green-50 border-green-200"
: "bg-white border-gray-200"
}`}
>
<div className="w-full">
<p className="text-gray-700">{rec.recommendationText}</p>
{rec.type === "ai_plan" && (
<div className="mt-2 text-xs text-gray-600 space-y-1">
{rec.activityPlan && (
<p>
<span className="font-semibold">Activity:</span>{" "}
{rec.activityPlan}
</p>
)}
{rec.dietPlan && (
<p>
<span className="font-semibold">Diet:</span>{" "}
{rec.dietPlan}
</p>
)}
</div>
</CardContent>
</Card>
);
)}
<p className="text-xs text-gray-400 mt-2">
{new Date(rec.createdAt).toLocaleDateString()} -{" "}
<span
className={
rec.status === "completed" || rec.status === "approved"
? "text-green-600 font-medium"
: "text-yellow-600"
}
>
{rec.status === "completed"
? "Completed"
: rec.status === "approved"
? "Approved"
: "Pending"}
</span>
</p>
</div>
</div>
))}
</div>
{type !== "ai_plan" && (
<form onSubmit={handleAddRecommendation} className="mt-3 flex gap-2">
<input
type="hidden"
value={type}
onChange={() => setNewRec({ ...newRec, type: type as any })}
/>
{newRec.type === type && (
<>
<input
type="text"
placeholder={`Add ${title.toLowerCase()}...`}
className="flex-1 border rounded px-3 py-1 text-sm"
value={newRec.content}
onChange={(e) =>
setNewRec({ ...newRec, content: e.target.value })
}
required
/>
<Button type="submit" variant="secondary">
Add
</Button>
</>
)}
{newRec.type !== type && (
<Button
type="button"
variant="secondary"
onClick={() => setNewRec({ type: type as any, content: "" })}
className="text-xs text-gray-500"
>
+ Add New
</Button>
)}
</form>
)}
</div>
);
if (loading) return <div>Loading recommendations...</div>;
return (
<Card>
<CardHeader>
<h3 className="text-xl font-bold">Fitness Recommendations</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{renderSection("Daily AI Plan", "ai_plan", groupedRecs.ai_plan)}
{renderSection(
"Short Term Goals",
"short_term",
groupedRecs.short_term,
)}
{renderSection(
"Medium Term Goals",
"medium_term",
groupedRecs.medium_term,
)}
{renderSection("Long Term Goals", "long_term", groupedRecs.long_term)}
</div>
</CardContent>
</Card>
);
}
@@ -6,6 +6,8 @@ import { UserGrid } from "@/components/users/UserGrid";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
import { getGymIdFromUser } from "@/lib/error-helpers";
import log from "@/lib/logger";
interface User {
id: string;
@@ -83,30 +85,27 @@ export function UserManagement() {
? `/api/users?ts=${ts}`
: `/api/users?role=${filter}&ts=${ts}`;
console.log("UserManagement.fetchUsers: fetching URL", url);
log.debug("Fetching users", { url });
const response = await fetch(url, { cache: "no-store" });
console.log(
"UserManagement.fetchUsers: response.ok",
response.ok,
"status",
response.status,
);
log.debug("Users fetch response", {
ok: response.ok,
status: response.status,
});
const data = await response.json();
console.log(
"UserManagement.fetchUsers: received users count",
Array.isArray(data.users) ? data.users.length : 0,
"sample",
data.users && data.users[0]
? {
id: data.users[0].id,
gymId: data.users[0].gymId,
role: data.users[0].role,
}
: null,
);
log.debug("Received users data", {
count: Array.isArray(data.users) ? data.users.length : 0,
sample:
data.users && data.users[0]
? {
id: data.users[0].id,
gymId: data.users[0].gymId,
role: data.users[0].role,
}
: null,
});
setUsers(data.users || []);
} catch (error) {
console.error("Failed to fetch users:", error);
log.error("Failed to fetch users", error);
} finally {
setLoading(false);
}
@@ -152,7 +151,7 @@ export function UserManagement() {
alert("Error deleting users");
}
} catch (error) {
console.error(error);
log.error("Failed to delete users", error);
}
};
@@ -201,37 +200,25 @@ export function UserManagement() {
try {
if (selectedUser) {
// Update existing user
console.log(
"UserManagement.handleSaveEdit: sending PUT /api/users payload",
{
id: selectedUser.id,
email: editForm.email,
firstName: editForm.firstName,
lastName: editForm.lastName,
role: editForm.role,
phone: editForm.phone,
gymId: editForm.gymId === "" ? null : editForm.gymId,
},
);
const payload = {
id: selectedUser.id,
email: editForm.email,
firstName: editForm.firstName,
lastName: editForm.lastName,
role: editForm.role,
phone: editForm.phone,
gymId: editForm.gymId === "" ? null : editForm.gymId,
};
log.debug("Updating user", payload);
const response = await fetch("/api/users", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: selectedUser.id,
email: editForm.email,
firstName: editForm.firstName,
lastName: editForm.lastName,
role: editForm.role,
phone: editForm.phone,
gymId: editForm.gymId === "" ? null : editForm.gymId,
}),
body: JSON.stringify(payload),
});
log.debug("User update response", {
ok: response.ok,
status: response.status,
});
console.log(
"UserManagement.handleSaveEdit: PUT /api/users response.ok",
response.ok,
"status",
response.status,
);
if (response.ok) {
// Optimistically update local state so grid reflects changes immediately
setUsers((prev) =>
@@ -265,15 +252,12 @@ export function UserManagement() {
setIsEditing(false);
setEditForm(null);
// Still re-fetch from server to ensure consistency
console.log(
"UserManagement.handleSaveEdit: re-fetching users after successful edit",
);
log.debug("Re-fetching users after successful edit");
fetchUsers();
} else {
const errText = await response.text().catch(() => "");
console.error("UserManagement.handleSaveEdit: update failed", {
log.error("User update failed", new Error(errText), {
status: response.status,
body: errText,
});
alert("Error updating user");
}
@@ -320,7 +304,7 @@ export function UserManagement() {
alert("Error deleting user");
}
} catch (error) {
console.error(error);
log.error("Failed to delete user", error);
}
};
@@ -351,7 +335,7 @@ export function UserManagement() {
email: "",
role: "client",
phone: "",
gymId: String((user?.publicMetadata as any)?.gymId ?? ""),
gymId: user ? getGymIdFromUser(user) : "",
});
setSelectedUser(null);
setIsEditing(true);
+40 -30
View File
@@ -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
+7 -4
View File
@@ -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;
}
}
+104
View File
@@ -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;
}
+16 -12
View File
@@ -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;
}
}
+680 -50
View File
@@ -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),
};
}
}
+59
View File
@@ -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
+88
View File
@@ -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";
}
+90
View File
@@ -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 "";
}
+252
View File
@@ -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;
}
+159
View File
@@ -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;
+90
View File
@@ -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 };
}
+121
View File
@@ -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);
}
+8 -7
View File
@@ -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";
})(),
});
+18 -7
View File
@@ -1,4 +1,16 @@
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { validateEnv } from "./lib/env";
import log from "./lib/logger";
// Validate environment variables on server startup
try {
validateEnv();
log.info("Environment validation passed");
} catch (error) {
log.error("Environment validation failed", error);
// Re-throw to prevent server from starting with invalid config
throw error;
}
// Define routes that should be publicly accessible (no auth required)
const isPublicRoute = createRouteMatcher([
@@ -16,27 +28,26 @@ export default clerkMiddleware(async (auth, req) => {
// Log for debugging
const authHeader = req.headers.get("authorization");
if (authHeader) {
console.log(
"[Middleware] Authorization header present:",
authHeader.substring(0, 20) + "...",
);
log.debug("Authorization header present", {
preview: authHeader.substring(0, 20) + "...",
});
}
// Don't protect public routes
if (isPublicRoute(req)) {
console.log("[Middleware] Public route, skipping auth");
log.debug("Public route, skipping auth");
return;
}
// For API routes, let the route handler check auth
// This allows API routes to handle both web sessions and mobile Bearer tokens
if (isApiRoute(req)) {
console.log("[Middleware] API route, auth will be checked in handler");
log.debug("API route, auth will be checked in handler");
return;
}
// For all other routes (web pages), enforce authentication
console.log("[Middleware] Protected route, requiring auth");
log.debug("Protected route, requiring auth");
await auth.protect();
});