enhancment complete with phase 10

This commit is contained in:
dimitar committed 2026-03-10 05:37:22 +01:00
1 parent 2c4927478a
commit 6adb541967
44 files changed
+7820 -845

No files matched your search

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