enhancment complete with phase 10
This commit is contained in:
1 parent
2c4927478a
commit
6adb541967
44 files changed
+7820
-845
No files matched your search
@@ -1,10 +1,15 @@
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import log from "@/lib/logger";
|
||||
import { checkInSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
@@ -2,48 +2,50 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
import { userLoginSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
unauthorizedResponse,
|
||||
internalErrorResponse,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const { email, password } = await request.json();
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, userLoginSchema);
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email and password are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!validation.success) {
|
||||
log.warn("Login validation failed", { errors: validation.errors });
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const { email, password } = validation.data;
|
||||
|
||||
const db = await getDatabase();
|
||||
const user = await db.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
return unauthorizedResponse("Invalid credentials");
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid credentials" },
|
||||
{ status: 401 },
|
||||
);
|
||||
return unauthorizedResponse("Invalid credentials");
|
||||
}
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
|
||||
return NextResponse.json({
|
||||
return successResponse({
|
||||
message: "Login successful",
|
||||
user: userWithoutPassword,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Login failed", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,25 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
import { userSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const { email, password, firstName, lastName, phone } =
|
||||
await request.json();
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, userSchema);
|
||||
|
||||
if (!email || !password || !firstName || !lastName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!validation.success) {
|
||||
log.warn("Registration validation failed", { errors: validation.errors });
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const { email, password, firstName, lastName, phone } = validation.data;
|
||||
|
||||
const db = await getDatabase();
|
||||
const existingUser = await db.getUserByEmail(email);
|
||||
|
||||
if (existingUser) {
|
||||
|
||||
@@ -2,19 +2,23 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
import { fitnessGoalUpdateSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
unauthorizedResponse,
|
||||
notFoundResponse,
|
||||
forbiddenResponse,
|
||||
internalErrorResponse,
|
||||
addCorsHeaders,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
// OPTIONS - Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return NextResponse.json({}, { headers: corsHeaders() });
|
||||
return NextResponse.json({}, { headers: addCorsHeaders() });
|
||||
}
|
||||
|
||||
// GET - Get specific goal
|
||||
@@ -25,10 +29,7 @@ export async function GET(
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -37,27 +38,18 @@ export async function GET(
|
||||
const goal = await db.getFitnessGoalById(id);
|
||||
|
||||
if (!goal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
return notFoundResponse("Goal not found", addCorsHeaders());
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if (goal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
return forbiddenResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
return NextResponse.json(goal, { headers: corsHeaders() });
|
||||
return successResponse(goal, { headers: addCorsHeaders() });
|
||||
} catch (error) {
|
||||
log.error("Failed to fetch fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,10 +61,7 @@ export async function PUT(
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -81,34 +70,36 @@ export async function PUT(
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
return notFoundResponse("Goal not found", addCorsHeaders());
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
return forbiddenResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const updates = await req.json();
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(req, fitnessGoalUpdateSchema);
|
||||
|
||||
// Don't allow changing userId or id
|
||||
delete updates.userId;
|
||||
delete updates.id;
|
||||
delete updates.createdAt;
|
||||
if (!validation.success) {
|
||||
log.warn("Fitness goal update validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
// Transform targetDate from string to Date if present
|
||||
const updates = {
|
||||
...validation.data,
|
||||
targetDate: validation.data.targetDate
|
||||
? new Date(validation.data.targetDate)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const updatedGoal = await db.updateFitnessGoal(id, updates);
|
||||
|
||||
return NextResponse.json(updatedGoal, { headers: corsHeaders() });
|
||||
return successResponse(updatedGoal, { headers: addCorsHeaders() });
|
||||
} catch (error) {
|
||||
log.error("Failed to update fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,10 +111,7 @@ export async function DELETE(
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
@@ -132,33 +120,21 @@ export async function DELETE(
|
||||
// Verify goal exists and user owns it
|
||||
const existingGoal = await db.getFitnessGoalById(id);
|
||||
if (!existingGoal) {
|
||||
return NextResponse.json(
|
||||
{ error: "Goal not found" },
|
||||
{ status: 404, headers: corsHeaders() },
|
||||
);
|
||||
return notFoundResponse("Goal not found", addCorsHeaders());
|
||||
}
|
||||
if (existingGoal.userId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden" },
|
||||
{ status: 403, headers: corsHeaders() },
|
||||
);
|
||||
return forbiddenResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
const deleted = await db.deleteFitnessGoal(id);
|
||||
|
||||
if (deleted) {
|
||||
return NextResponse.json({ success: true }, { headers: corsHeaders() });
|
||||
return successResponse({ deleted: true }, { headers: addCorsHeaders() });
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete goal" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse("Failed to delete goal", addCorsHeaders());
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,23 @@ import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { randomBytes } from "crypto";
|
||||
import log from "@/lib/logger";
|
||||
|
||||
// Helper to add CORS headers
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
}
|
||||
import {
|
||||
fitnessGoalSchema,
|
||||
paginationSchema,
|
||||
statusFilterSchema,
|
||||
} from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validateQueryParams,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
createdResponse,
|
||||
unauthorizedResponse,
|
||||
internalErrorResponse,
|
||||
addCorsHeaders,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
// Helper to get user ID from auth (works with both web sessions and mobile tokens)
|
||||
async function getAuthenticatedUserId(
|
||||
@@ -25,7 +33,7 @@ async function getAuthenticatedUserId(
|
||||
|
||||
// OPTIONS - Handle preflight requests
|
||||
export async function OPTIONS() {
|
||||
return NextResponse.json({}, { headers: corsHeaders() });
|
||||
return NextResponse.json({}, { headers: addCorsHeaders() });
|
||||
}
|
||||
|
||||
// GET - List user's fitness goals
|
||||
@@ -37,10 +45,7 @@ export async function GET(req: NextRequest) {
|
||||
log.error("Fitness goals GET - authentication failed", undefined, {
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
log.debug("Fitness goals GET - authenticated user", { userId });
|
||||
@@ -61,13 +66,10 @@ export async function GET(req: NextRequest) {
|
||||
count: goals.length,
|
||||
userId: targetUserId,
|
||||
});
|
||||
return NextResponse.json(goals, { headers: corsHeaders() });
|
||||
return successResponse(goals, { headers: addCorsHeaders() });
|
||||
} catch (error) {
|
||||
log.error("Failed to get fitness goals", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,13 +80,19 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (!userId) {
|
||||
log.error("Fitness goals POST - authentication failed");
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401, headers: corsHeaders() },
|
||||
);
|
||||
return unauthorizedResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(req, fitnessGoalSchema);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Fitness goal creation validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
goalType,
|
||||
title,
|
||||
@@ -94,17 +102,9 @@ export async function POST(req: NextRequest) {
|
||||
unit,
|
||||
targetDate,
|
||||
priority,
|
||||
status,
|
||||
notes,
|
||||
fitnessProfileId,
|
||||
} = body;
|
||||
|
||||
// Validation
|
||||
if (!goalType || !title) {
|
||||
return NextResponse.json(
|
||||
{ error: "goalType and title are required" },
|
||||
{ status: 400, headers: corsHeaders() },
|
||||
);
|
||||
}
|
||||
} = validation.data;
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
@@ -112,28 +112,25 @@ export async function POST(req: NextRequest) {
|
||||
const goal = await db.createFitnessGoal({
|
||||
id: randomBytes(16).toString("hex"),
|
||||
userId,
|
||||
fitnessProfileId: fitnessProfileId || undefined,
|
||||
fitnessProfileId: undefined,
|
||||
goalType,
|
||||
title,
|
||||
description: description || undefined,
|
||||
targetValue: targetValue || undefined,
|
||||
description,
|
||||
targetValue,
|
||||
currentValue: currentValue || 0,
|
||||
unit: unit || undefined,
|
||||
unit,
|
||||
startDate: new Date(),
|
||||
targetDate: targetDate ? new Date(targetDate) : undefined,
|
||||
status: "active",
|
||||
status: status || "active",
|
||||
progress: 0,
|
||||
priority: priority || "medium",
|
||||
notes: notes || undefined,
|
||||
notes,
|
||||
});
|
||||
|
||||
log.info("Fitness goal created", { goalId: goal.id, userId });
|
||||
return NextResponse.json(goal, { status: 201, headers: corsHeaders() });
|
||||
return createdResponse(goal, { headers: addCorsHeaders() });
|
||||
} catch (error) {
|
||||
log.error("Failed to create fitness goal", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500, headers: corsHeaders() },
|
||||
);
|
||||
return internalErrorResponse(undefined, addCorsHeaders());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import { auth } from "@clerk/nextjs/server";
|
||||
import Database from "better-sqlite3";
|
||||
import { randomBytes } from "crypto";
|
||||
import log from "@/lib/logger";
|
||||
import { fitnessProfileSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
|
||||
const db = new Database("./data/fitai.db");
|
||||
|
||||
@@ -48,20 +53,28 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, fitnessProfileSchema);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Fitness profile validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const {
|
||||
height,
|
||||
weight,
|
||||
age,
|
||||
gender,
|
||||
fitnessGoals, // Changed from fitnessGoal
|
||||
fitnessGoals,
|
||||
activityLevel,
|
||||
medicalConditions,
|
||||
allergies,
|
||||
injuries,
|
||||
exerciseHabits,
|
||||
dietHabits,
|
||||
} = body;
|
||||
preferences,
|
||||
} = validation.data;
|
||||
|
||||
// Check if profile exists
|
||||
const existingProfile = db
|
||||
@@ -84,22 +97,20 @@ export async function POST(request: NextRequest) {
|
||||
medicalConditions = ?,
|
||||
allergies = ?,
|
||||
injuries = ?,
|
||||
exerciseHabits = ?,
|
||||
dietHabits = ?,
|
||||
preferences = ?,
|
||||
updatedAt = ?
|
||||
WHERE userId = ?`,
|
||||
).run(
|
||||
height || null,
|
||||
weight || null,
|
||||
age || null,
|
||||
height,
|
||||
weight,
|
||||
age,
|
||||
gender || null,
|
||||
fitnessGoalsJson,
|
||||
activityLevel || null,
|
||||
medicalConditions || null,
|
||||
allergies || null,
|
||||
injuries || null,
|
||||
exerciseHabits || null,
|
||||
dietHabits || null,
|
||||
preferences || null,
|
||||
now,
|
||||
userId,
|
||||
);
|
||||
@@ -113,21 +124,20 @@ export async function POST(request: NextRequest) {
|
||||
db.prepare(
|
||||
`INSERT INTO fitness_profiles
|
||||
(userId, height, weight, age, gender, fitnessGoals, activityLevel,
|
||||
medicalConditions, allergies, injuries, exerciseHabits, dietHabits, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
medicalConditions, allergies, injuries, preferences, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
userId,
|
||||
height || null,
|
||||
weight || null,
|
||||
age || null,
|
||||
height,
|
||||
weight,
|
||||
age,
|
||||
gender || null,
|
||||
fitnessGoalsJson,
|
||||
activityLevel || null,
|
||||
medicalConditions || null,
|
||||
allergies || null,
|
||||
injuries || null,
|
||||
exerciseHabits || null,
|
||||
dietHabits || null,
|
||||
preferences || null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "../../../../lib/database/index";
|
||||
import log from "@/lib/logger";
|
||||
import { fitnessProfileSchema } from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const profileData = await request.json();
|
||||
|
||||
if (
|
||||
!profileData.userId ||
|
||||
!profileData.height ||
|
||||
!profileData.weight ||
|
||||
!profileData.age
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, fitnessProfileSchema);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Fitness profile validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const profileData = validation.data;
|
||||
|
||||
// Check if user exists
|
||||
const user = await db.getUserById(profileData.userId);
|
||||
if (!user) {
|
||||
@@ -32,9 +36,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
let profile;
|
||||
if (existingProfile) {
|
||||
profile = await db.updateFitnessProfile(profileData.userId, profileData);
|
||||
// Update - omit userId from update data
|
||||
const { userId, ...updateData } = profileData;
|
||||
profile = await db.updateFitnessProfile(userId, updateData as any);
|
||||
} else {
|
||||
profile = await db.createFitnessProfile(profileData);
|
||||
// Create - omit userId since the function signature expects it separately
|
||||
const { userId, ...createData } = profileData;
|
||||
profile = await db.createFitnessProfile({ userId, ...createData } as any);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import log from "@/lib/logger";
|
||||
import {
|
||||
legacyRecommendationSchema,
|
||||
recommendationUpdateRequestSchema,
|
||||
} from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
unauthorizedResponse,
|
||||
forbiddenResponse,
|
||||
badRequestResponse,
|
||||
internalErrorResponse,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -24,11 +39,11 @@ export async function GET(request: Request) {
|
||||
currentUser?.role === "trainer";
|
||||
|
||||
if (!isStaff) {
|
||||
return new NextResponse("User ID is required", { status: 400 });
|
||||
return badRequestResponse("User ID is required");
|
||||
}
|
||||
|
||||
const recommendations = await db.getAllRecommendations();
|
||||
return NextResponse.json({ recommendations });
|
||||
return successResponse({ recommendations });
|
||||
}
|
||||
|
||||
// Check permissions: Users can view their own, Admins/Trainers can view anyone's
|
||||
@@ -40,7 +55,7 @@ export async function GET(request: Request) {
|
||||
|
||||
if (currentUserId !== targetUserId) {
|
||||
if (!isStaff) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
return forbiddenResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +68,18 @@ export async function GET(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(recommendations);
|
||||
return successResponse(recommendations);
|
||||
} catch (error) {
|
||||
console.error("Error fetching recommendations:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
@@ -75,10 +90,22 @@ export async function POST(request: Request) {
|
||||
currentUser?.role === "trainer";
|
||||
|
||||
if (!isStaff) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
return forbiddenResponse();
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(
|
||||
request,
|
||||
legacyRecommendationSchema,
|
||||
);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Recommendation creation validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const {
|
||||
userId,
|
||||
fitnessProfileId,
|
||||
@@ -88,7 +115,7 @@ export async function POST(request: Request) {
|
||||
status,
|
||||
type,
|
||||
content,
|
||||
} = body;
|
||||
} = validation.data;
|
||||
|
||||
// Handle AI Plan (Legacy/Specific)
|
||||
if (recommendationText && activityPlan && dietPlan && fitnessProfileId) {
|
||||
@@ -101,8 +128,8 @@ export async function POST(request: Request) {
|
||||
activityPlan,
|
||||
dietPlan,
|
||||
status: status || "pending",
|
||||
});
|
||||
return NextResponse.json(recommendation);
|
||||
} as any);
|
||||
return successResponse(recommendation);
|
||||
}
|
||||
|
||||
// Handle User Goal (Generic)
|
||||
@@ -113,31 +140,39 @@ export async function POST(request: Request) {
|
||||
type,
|
||||
recommendationText: content,
|
||||
status: status || "pending",
|
||||
});
|
||||
return NextResponse.json(recommendation);
|
||||
} as any);
|
||||
return successResponse(recommendation);
|
||||
}
|
||||
|
||||
return NextResponse.json("Missing required fields", { status: 400 });
|
||||
return badRequestResponse("Missing required fields");
|
||||
} catch (error) {
|
||||
log.error("Failed to create recommendation", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { userId: currentUserId } = await auth();
|
||||
if (!currentUserId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(
|
||||
request,
|
||||
recommendationUpdateRequestSchema,
|
||||
);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("Recommendation update validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, status, recommendationText, activityPlan, dietPlan, content } =
|
||||
body;
|
||||
|
||||
if (!id) {
|
||||
return new NextResponse("Recommendation ID is required", { status: 400 });
|
||||
}
|
||||
validation.data;
|
||||
|
||||
const db = await getDatabase();
|
||||
|
||||
@@ -147,11 +182,11 @@ export async function PUT(request: Request) {
|
||||
...(content && { recommendationText: content }),
|
||||
...(activityPlan && { activityPlan }),
|
||||
...(dietPlan && { dietPlan }),
|
||||
});
|
||||
} as any);
|
||||
|
||||
return NextResponse.json(updated);
|
||||
return successResponse(updated);
|
||||
} catch (error) {
|
||||
log.error("Failed to update recommendation", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { GET } from "../route";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("@/lib/database/index");
|
||||
jest.mock("@clerk/nextjs/server");
|
||||
jest.mock("@fitai/database", () => ({
|
||||
db: {
|
||||
all: jest.fn(),
|
||||
},
|
||||
sql: jest.fn(),
|
||||
}));
|
||||
jest.mock("@/lib/logger");
|
||||
|
||||
describe("GET /api/users", () => {
|
||||
const mockGetDatabase = require("@/lib/database/index").getDatabase;
|
||||
const mockDb = require("@fitai/database").db;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Batch Query Optimization", () => {
|
||||
it("should use batch methods instead of individual queries", async () => {
|
||||
// Mock database methods
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "user1",
|
||||
email: "user1@test.com",
|
||||
firstName: "User",
|
||||
lastName: "One",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "user2",
|
||||
email: "user2@test.com",
|
||||
firstName: "User",
|
||||
lastName: "Two",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "client1",
|
||||
userId: "user1",
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
},
|
||||
]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
|
||||
// Mock raw DB queries for gym data
|
||||
mockDb.all.mockImplementation(async (query: any) => {
|
||||
const queryStr = query.toString();
|
||||
if (queryStr.includes("users")) {
|
||||
return [
|
||||
{ id: "user1", gym_id: null },
|
||||
{ id: "user2", gym_id: null },
|
||||
];
|
||||
}
|
||||
if (queryStr.includes("gyms")) {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Create mock request
|
||||
const request = new NextRequest("http://localhost:3000/api/users");
|
||||
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify batch methods were called
|
||||
expect(mockBatchMethods.getClientsByUserIds).toHaveBeenCalledWith([
|
||||
"user1",
|
||||
"user2",
|
||||
]);
|
||||
expect(mockBatchMethods.getActiveCheckInsByUserIds).toHaveBeenCalledWith([
|
||||
"user1",
|
||||
"user2",
|
||||
]);
|
||||
expect(
|
||||
mockBatchMethods.getAttendanceHistoriesByUserIds,
|
||||
).toHaveBeenCalledWith(["user1", "user2"]);
|
||||
|
||||
// Verify each batch method was called only ONCE
|
||||
expect(mockBatchMethods.getClientsByUserIds).toHaveBeenCalledTimes(1);
|
||||
expect(mockBatchMethods.getActiveCheckInsByUserIds).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
mockBatchMethods.getAttendanceHistoriesByUserIds,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify response structure
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.data.users).toHaveLength(2);
|
||||
expect(data.data.users[0]).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("should handle empty user list efficiently", async () => {
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
mockDb.all.mockResolvedValue([]);
|
||||
|
||||
const request = new NextRequest("http://localhost:3000/api/users");
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
// Verify batch methods were called with empty arrays
|
||||
expect(mockBatchMethods.getClientsByUserIds).toHaveBeenCalledWith([]);
|
||||
expect(mockBatchMethods.getActiveCheckInsByUserIds).toHaveBeenCalledWith(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
mockBatchMethods.getAttendanceHistoriesByUserIds,
|
||||
).toHaveBeenCalledWith([]);
|
||||
|
||||
expect(data.data.users).toEqual([]);
|
||||
});
|
||||
|
||||
it("should filter by role without breaking batch optimization", async () => {
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "user1",
|
||||
email: "user1@test.com",
|
||||
firstName: "User",
|
||||
lastName: "One",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "user2",
|
||||
email: "user2@test.com",
|
||||
firstName: "User",
|
||||
lastName: "Two",
|
||||
role: "admin",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
mockDb.all.mockImplementation(async (query: any) => {
|
||||
const queryStr = query.toString();
|
||||
if (queryStr.includes("users")) {
|
||||
return [
|
||||
{ id: "user1", gym_id: null },
|
||||
{ id: "user2", gym_id: null },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost:3000/api/users?role=client",
|
||||
);
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
// Should still use batch methods for filtered users
|
||||
expect(mockBatchMethods.getClientsByUserIds).toHaveBeenCalledWith([
|
||||
"user1",
|
||||
]);
|
||||
|
||||
expect(data.data.users).toHaveLength(1);
|
||||
expect(data.data.users[0].role).toBe("client");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Response Structure", () => {
|
||||
it("should include client data in response", async () => {
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "user1",
|
||||
email: "user1@test.com",
|
||||
firstName: "User",
|
||||
lastName: "One",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "client1",
|
||||
userId: "user1",
|
||||
membershipType: "premium",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
},
|
||||
]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
mockDb.all.mockImplementation(async (query: any) => {
|
||||
const queryStr = query.toString();
|
||||
if (queryStr.includes("users")) {
|
||||
return [{ id: "user1", gym_id: null }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const request = new NextRequest("http://localhost:3000/api/users");
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.data.users[0].client).toBeDefined();
|
||||
expect(data.data.users[0].client.membershipType).toBe("premium");
|
||||
});
|
||||
|
||||
it("should include attendance statistics", async () => {
|
||||
const weekAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
|
||||
const twoWeeksAgo = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const mockBatchMethods = {
|
||||
getAllUsers: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "user1",
|
||||
email: "user1@test.com",
|
||||
firstName: "User",
|
||||
lastName: "One",
|
||||
role: "client",
|
||||
password: "hashed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
getClientsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getActiveCheckInsByUserIds: jest.fn().mockResolvedValue([]),
|
||||
getAttendanceHistoriesByUserIds: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: "att1",
|
||||
userId: "user1",
|
||||
checkInTime: weekAgo,
|
||||
checkOutTime: new Date(),
|
||||
},
|
||||
{
|
||||
id: "att2",
|
||||
userId: "user1",
|
||||
checkInTime: twoWeeksAgo,
|
||||
checkOutTime: new Date(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
mockGetDatabase.mockResolvedValue(mockBatchMethods);
|
||||
mockDb.all.mockImplementation(async (query: any) => {
|
||||
const queryStr = query.toString();
|
||||
if (queryStr.includes("users")) {
|
||||
return [{ id: "user1", gym_id: null }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const request = new NextRequest("http://localhost:3000/api/users");
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.data.users[0].checkInsThisWeek).toBe(1);
|
||||
expect(data.data.users[0].checkInsThisMonth).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,26 @@ import bcrypt from "bcryptjs";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { db as rawDb, sql } from "@fitai/database";
|
||||
import log from "@/lib/logger";
|
||||
import {
|
||||
roleFilterSchema,
|
||||
userInviteSchema,
|
||||
userUpdateWithIdSchema,
|
||||
} from "@/lib/validation/schemas";
|
||||
import {
|
||||
validateRequestBody,
|
||||
validateQueryParams,
|
||||
validationErrorResponse,
|
||||
} from "@/lib/validation/helpers";
|
||||
import {
|
||||
successResponse,
|
||||
createdResponse,
|
||||
unauthorizedResponse,
|
||||
forbiddenResponse,
|
||||
notFoundResponse,
|
||||
conflictResponse,
|
||||
internalErrorResponse,
|
||||
badRequestResponse,
|
||||
} from "@/lib/api/responses";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -52,67 +72,86 @@ export async function GET(request: NextRequest) {
|
||||
: null,
|
||||
});
|
||||
|
||||
const usersWithClients = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
const client = await db.getClientByUserId(user.id);
|
||||
// Batch load all related data at once to avoid N+1 queries
|
||||
const userIds = users.map((u) => u.id);
|
||||
const [allClients, allActiveCheckIns, allAttendanceHistories] =
|
||||
await Promise.all([
|
||||
db.getClientsByUserIds(userIds),
|
||||
db.getActiveCheckInsByUserIds(userIds),
|
||||
db.getAttendanceHistoriesByUserIds(userIds),
|
||||
]);
|
||||
|
||||
// Get active check-in status and statistics for ALL users
|
||||
let isCheckedIn = false;
|
||||
let checkInTime = null;
|
||||
let lastCheckInTime = null;
|
||||
let checkInsThisWeek = 0;
|
||||
let checkInsThisMonth = 0;
|
||||
|
||||
// Query attendance by userId (works for all user types now)
|
||||
const activeCheckIn = await db.getActiveCheckIn(user.id);
|
||||
if (activeCheckIn) {
|
||||
isCheckedIn = true;
|
||||
checkInTime = activeCheckIn.checkInTime;
|
||||
}
|
||||
|
||||
// Get attendance history for statistics
|
||||
const attendanceHistory = await db.getAttendanceHistory(user.id);
|
||||
|
||||
if (attendanceHistory.length > 0) {
|
||||
// Last check-in is the most recent attendance record
|
||||
lastCheckInTime = attendanceHistory[0].checkInTime;
|
||||
|
||||
// Calculate check-ins in last 7 days
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
checkInsThisWeek = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
||||
).length;
|
||||
|
||||
// Calculate check-ins in last 30 days
|
||||
const monthAgo = new Date();
|
||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||
checkInsThisMonth = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
||||
).length;
|
||||
}
|
||||
|
||||
return {
|
||||
...userWithoutPassword,
|
||||
// Override gymId from raw DB hydration to avoid undefined from Drizzle mapping
|
||||
gymId: gymById.get(user.id) ?? (user as any).gymId ?? undefined,
|
||||
// Provide gymName mapped from gyms table
|
||||
gymName: (() => {
|
||||
const gid =
|
||||
gymById.get(user.id) ?? (user as any).gymId ?? undefined;
|
||||
if (!gid) return null;
|
||||
return gymNames.get(gid) ?? null;
|
||||
})(),
|
||||
client,
|
||||
isCheckedIn,
|
||||
checkInTime,
|
||||
lastCheckInTime,
|
||||
checkInsThisWeek,
|
||||
checkInsThisMonth,
|
||||
};
|
||||
}),
|
||||
// Create lookup maps for O(1) access
|
||||
const clientsByUserId = new Map(allClients.map((c) => [c.userId, c]));
|
||||
const activeCheckInsByUserId = new Map(
|
||||
allActiveCheckIns.map((a) => [a.userId, a]),
|
||||
);
|
||||
// Group attendance histories by userId
|
||||
const attendanceByUserId = new Map<string, typeof allAttendanceHistories>();
|
||||
for (const attendance of allAttendanceHistories) {
|
||||
const existing = attendanceByUserId.get(attendance.userId) || [];
|
||||
existing.push(attendance);
|
||||
attendanceByUserId.set(attendance.userId, existing);
|
||||
}
|
||||
|
||||
const usersWithClients = users.map((user) => {
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
const client = clientsByUserId.get(user.id) || null;
|
||||
|
||||
// Get active check-in status and statistics for ALL users
|
||||
let isCheckedIn = false;
|
||||
let checkInTime = null;
|
||||
let lastCheckInTime = null;
|
||||
let checkInsThisWeek = 0;
|
||||
let checkInsThisMonth = 0;
|
||||
|
||||
// Query attendance by userId (works for all user types now)
|
||||
const activeCheckIn = activeCheckInsByUserId.get(user.id);
|
||||
if (activeCheckIn) {
|
||||
isCheckedIn = true;
|
||||
checkInTime = activeCheckIn.checkInTime;
|
||||
}
|
||||
|
||||
// Get attendance history for statistics
|
||||
const attendanceHistory = attendanceByUserId.get(user.id) || [];
|
||||
|
||||
if (attendanceHistory.length > 0) {
|
||||
// Last check-in is the most recent attendance record
|
||||
lastCheckInTime = attendanceHistory[0].checkInTime;
|
||||
|
||||
// Calculate check-ins in last 7 days
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
checkInsThisWeek = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
||||
).length;
|
||||
|
||||
// Calculate check-ins in last 30 days
|
||||
const monthAgo = new Date();
|
||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||
checkInsThisMonth = attendanceHistory.filter(
|
||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
||||
).length;
|
||||
}
|
||||
|
||||
return {
|
||||
...userWithoutPassword,
|
||||
// Override gymId from raw DB hydration to avoid undefined from Drizzle mapping
|
||||
gymId: gymById.get(user.id) ?? (user as any).gymId ?? undefined,
|
||||
// Provide gymName mapped from gyms table
|
||||
gymName: (() => {
|
||||
const gid = gymById.get(user.id) ?? (user as any).gymId ?? undefined;
|
||||
if (!gid) return null;
|
||||
return gymNames.get(gid) ?? null;
|
||||
})(),
|
||||
client,
|
||||
isCheckedIn,
|
||||
checkInTime,
|
||||
lastCheckInTime,
|
||||
checkInsThisWeek,
|
||||
checkInsThisMonth,
|
||||
};
|
||||
});
|
||||
|
||||
log.debug("Prepared response with user details", {
|
||||
usersCount: Array.isArray(usersWithClients) ? usersWithClients.length : 0,
|
||||
@@ -125,13 +164,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
: null,
|
||||
});
|
||||
return NextResponse.json({ users: usersWithClients });
|
||||
return successResponse({ users: usersWithClients });
|
||||
} catch (error) {
|
||||
log.error("Failed to get users", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +175,7 @@ export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
@@ -162,22 +198,21 @@ export async function POST(request: NextRequest) {
|
||||
const currentUser = await db.getUserById(clerkUserId);
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "Current user not found in database" },
|
||||
{ status: 403 },
|
||||
);
|
||||
return forbiddenResponse("Current user not found in database");
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { email, firstName, lastName, role, phone } = body;
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(request, userInviteSchema);
|
||||
|
||||
if (!email || !firstName || !lastName || !role) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (!validation.success) {
|
||||
log.warn("User invitation validation failed", {
|
||||
errors: validation.errors,
|
||||
});
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const { email, firstName, lastName, role, phone } = validation.data;
|
||||
|
||||
// Enforce Hierarchy
|
||||
const allowed: Record<string, string[]> = {
|
||||
superAdmin: ["admin", "trainer", "client"],
|
||||
@@ -188,19 +223,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const userRole = currentUser.role as keyof typeof allowed;
|
||||
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: `You are not authorized to create a ${role}` },
|
||||
{ status: 403 },
|
||||
);
|
||||
return forbiddenResponse(`You are not authorized to create a ${role}`);
|
||||
}
|
||||
|
||||
// Check if user already exists locally
|
||||
const existingUser = await db.getUserByEmail(email);
|
||||
if (existingUser) {
|
||||
return NextResponse.json(
|
||||
{ error: "User with this email already exists" },
|
||||
{ status: 409 },
|
||||
);
|
||||
return conflictResponse("User with this email already exists");
|
||||
}
|
||||
|
||||
// Create Clerk Invitation
|
||||
@@ -223,18 +252,10 @@ export async function POST(request: NextRequest) {
|
||||
// If user already exists in Clerk, we might want to handle it.
|
||||
// But for now, let's proceed to create local record if invite sent or if they exist.
|
||||
if (clerkError.errors?.[0]?.code === "form_identifier_exists") {
|
||||
return NextResponse.json(
|
||||
{ error: "User already exists in Clerk system" },
|
||||
{ status: 409 },
|
||||
);
|
||||
return conflictResponse("User already exists in Clerk system");
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Failed to send invitation: " +
|
||||
(clerkError.message || "Unknown error"),
|
||||
},
|
||||
{ status: 500 },
|
||||
return internalErrorResponse(
|
||||
"Failed to send invitation: " + (clerkError.message || "Unknown error"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,24 +280,32 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ userId: newUserId.id, message: "Invitation sent" },
|
||||
{ status: 201 },
|
||||
);
|
||||
return createdResponse({
|
||||
userId: newUserId.id,
|
||||
message: "Invitation sent",
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Failed to create user", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const body = await request.json();
|
||||
const { id, email, firstName, lastName, role, phone, gymId } = body;
|
||||
// Validate request body
|
||||
const validation = await validateRequestBody(
|
||||
request,
|
||||
userUpdateWithIdSchema,
|
||||
);
|
||||
|
||||
if (!validation.success) {
|
||||
log.warn("User update validation failed", { errors: validation.errors });
|
||||
return validationErrorResponse(validation.errors);
|
||||
}
|
||||
|
||||
const { id, email, firstName, lastName, role, phone, gymId } =
|
||||
validation.data;
|
||||
|
||||
log.debug("Updating user", {
|
||||
id,
|
||||
email,
|
||||
@@ -287,31 +316,23 @@ export async function PUT(request: NextRequest) {
|
||||
gymId,
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const db = await getDatabase();
|
||||
|
||||
// Authenticate requester
|
||||
const { userId: requesterId } = await auth();
|
||||
if (!requesterId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return unauthorizedResponse();
|
||||
}
|
||||
|
||||
// Fetch requester and target user
|
||||
const requester = await db.getUserById(requesterId);
|
||||
if (!requester) {
|
||||
return NextResponse.json(
|
||||
{ error: "Requester not found" },
|
||||
{ status: 403 },
|
||||
);
|
||||
return forbiddenResponse("Requester not found");
|
||||
}
|
||||
|
||||
const existingUser = await db.getUserById(id);
|
||||
if (!existingUser) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
return notFoundResponse("User not found");
|
||||
}
|
||||
|
||||
// Authorization: determine allowed role changes
|
||||
@@ -325,20 +346,14 @@ export async function PUT(request: NextRequest) {
|
||||
};
|
||||
|
||||
if (role && !allowedByRole[requesterRole]?.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Not authorized to assign role '${role}'` },
|
||||
{ status: 403 },
|
||||
);
|
||||
return forbiddenResponse(`Not authorized to assign role '${role}'`);
|
||||
}
|
||||
|
||||
// Check if email is being changed and if it's already taken
|
||||
if (email && email !== existingUser.email) {
|
||||
const userWithEmail = await db.getUserByEmail(email);
|
||||
if (userWithEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email already in use" },
|
||||
{ status: 409 },
|
||||
);
|
||||
return conflictResponse("Email already in use");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,9 +390,8 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
} catch (clerkErr: any) {
|
||||
log.error("Clerk metadata update failed", clerkErr, { userId: id });
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update role/gym in identity provider" },
|
||||
{ status: 500 },
|
||||
return internalErrorResponse(
|
||||
"Failed to update role/gym in identity provider",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -416,13 +430,10 @@ export async function PUT(request: NextRequest) {
|
||||
};
|
||||
log.debug("Sending updated user response", { updatedUser });
|
||||
|
||||
return NextResponse.json({ user: updatedUser });
|
||||
return successResponse({ user: updatedUser });
|
||||
} catch (error) {
|
||||
log.error("Failed to update user", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,26 +448,20 @@ export async function DELETE(request: NextRequest) {
|
||||
if (ids && Array.isArray(ids)) {
|
||||
// Bulk delete
|
||||
await Promise.all(ids.map((userId: string) => db.deleteUser(userId)));
|
||||
return NextResponse.json({ success: true, deleted: ids.length });
|
||||
return successResponse({ deleted: ids.length });
|
||||
} else if (id) {
|
||||
// Single delete
|
||||
const user = await db.getUserById(id);
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
return notFoundResponse("User not found");
|
||||
}
|
||||
await db.deleteUser(id);
|
||||
return NextResponse.json({ success: true });
|
||||
return successResponse({ deleted: 1 });
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "User ID or IDs array required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
return badRequestResponse("User ID or IDs array required");
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("Failed to delete user(s)", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
return internalErrorResponse();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user