Compare commits
15
Commits
quickAction
...
on2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
339d798a88 | ||
|
|
6580564767 | ||
|
|
d3a36b6103 | ||
|
|
868eaa5e3d | ||
|
|
5743ebc4f8 | ||
|
|
19ec020046 | ||
|
|
8353a24f6b | ||
|
|
670e6675cc | ||
|
|
cdd854bd70 | ||
|
|
cbb1f52467 | ||
|
|
62e2255b5e | ||
|
|
d7d270510f | ||
|
|
a5c297b911 | ||
|
|
0011c9d4e5 | ||
|
|
db0d2cf215 |
No files matched your search
Binary file not shown.
Executable → Regular
BIN
Binary file not shown.
Whitespace-only changes.
@@ -1,10 +1,11 @@
|
||||
const { pathsToModuleNameMapper } = require('ts-jest');
|
||||
const { compilerOptions } = require('./tsconfig');
|
||||
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
moduleNameMapping: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }),
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.(ts|tsx|js)',
|
||||
'**/*.(test|spec).(ts|tsx|js)',
|
||||
|
||||
@@ -1 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
require('@testing-library/jest-dom')
|
||||
Generated
+1257
-9
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,7 @@
|
||||
"@fitai/database": "file:../../packages/database",
|
||||
"@fitai/shared": "file:../../packages/shared",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.7",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
@@ -26,10 +27,11 @@
|
||||
"autoprefixer": "^10.4.16",
|
||||
"axios": "^1.13.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"better-sqlite3": "12.4.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.0.1",
|
||||
@@ -49,12 +51,15 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
import { getDatabase } from '../src/lib/database';
|
||||
import { DrizzleDatabase } from '../src/lib/database/drizzle';
|
||||
|
||||
async function checkUserRole() {
|
||||
const db = await getDatabase();
|
||||
const users = await db.getAllUsers();
|
||||
console.log('Users found:', users.length);
|
||||
users.forEach(u => {
|
||||
console.log(`User: ${u.email}, Role: ${u.role}, ID: ${u.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
checkUserRole().catch(console.error);
|
||||
@@ -0,0 +1,15 @@
|
||||
|
||||
import { getDatabase } from '../src/lib/database';
|
||||
|
||||
async function makeAdmin(email: string) {
|
||||
const db = await getDatabase();
|
||||
const user = await db.getUserByEmail(email);
|
||||
if (!user) {
|
||||
console.log('User not found');
|
||||
return;
|
||||
}
|
||||
await db.updateUser(user.id, { role: 'admin' });
|
||||
console.log(`User ${email} updated to admin`);
|
||||
}
|
||||
|
||||
makeAdmin('taratur@gmail.com').catch(console.error);
|
||||
@@ -0,0 +1,57 @@
|
||||
|
||||
import 'dotenv/config';
|
||||
import { getDatabase } from '../src/lib/database';
|
||||
import { clerkClient } from '@clerk/nextjs/server';
|
||||
|
||||
async function syncAllUsers() {
|
||||
console.log('Starting full user sync...');
|
||||
const db = await getDatabase();
|
||||
|
||||
// Fetch all users from Clerk
|
||||
// Note: pagination might be needed for large user bases, but for prototype this is fine.
|
||||
const client = await clerkClient();
|
||||
const response = await client.users.getUserList({ limit: 100 });
|
||||
const clerkUsers = response.data;
|
||||
|
||||
console.log(`Found ${clerkUsers.length} users in Clerk.`);
|
||||
|
||||
for (const clerkUser of clerkUsers) {
|
||||
const userId = clerkUser.id;
|
||||
const email = clerkUser.emailAddresses[0]?.emailAddress;
|
||||
|
||||
if (!email) {
|
||||
console.warn(`User ${userId} has no email, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingUser = await db.getUserById(userId);
|
||||
if (existingUser) {
|
||||
console.log(`User ${email} (${userId}) already exists.`);
|
||||
// Optional: Update details if needed
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check by email to handle ID mismatches (e.g. from seeding)
|
||||
const existingByEmail = await db.getUserByEmail(email);
|
||||
if (existingByEmail) {
|
||||
console.log(`User ${email} found with old ID ${existingByEmail.id}. Migrating to ${userId}...`);
|
||||
await db.migrateUserId(existingByEmail.id, userId);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Creating new user ${email} (${userId})...`);
|
||||
await db.createUser({
|
||||
id: userId,
|
||||
email,
|
||||
firstName: clerkUser.firstName || '',
|
||||
lastName: clerkUser.lastName || '',
|
||||
password: '', // Managed by Clerk
|
||||
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
||||
role: (clerkUser.publicMetadata.role as 'admin' | 'client' | 'superAdmin') || 'client'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Sync complete.');
|
||||
}
|
||||
|
||||
syncAllUsers().catch(console.error);
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"ts-node": {
|
||||
"transpileOnly": true,
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
|
||||
import { getDatabase } from '../src/lib/database/index';
|
||||
|
||||
async function verifyDatabase() {
|
||||
console.log('Starting database verification...');
|
||||
const db = await getDatabase();
|
||||
await db.connect();
|
||||
|
||||
try {
|
||||
// 1. Create User
|
||||
console.log('Creating test user...');
|
||||
const userId = `test-user-${Date.now()}`;
|
||||
const user = await db.createUser({
|
||||
id: userId,
|
||||
email: `test-${Date.now()}@example.com`,
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
password: 'password123',
|
||||
role: 'client',
|
||||
phone: '1234567890'
|
||||
});
|
||||
console.log('User created:', user.id);
|
||||
|
||||
// 2. Create Client
|
||||
console.log('Creating test client...');
|
||||
const client = await db.createClient({
|
||||
userId: user.id,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
});
|
||||
console.log('Client created:', client.id);
|
||||
|
||||
// 3. Create Fitness Profile
|
||||
console.log('Creating fitness profile...');
|
||||
const profile = await db.createFitnessProfile({
|
||||
id: 'test-profile-id',
|
||||
userId: user.id,
|
||||
height: '180',
|
||||
weight: '75',
|
||||
age: '30',
|
||||
gender: 'male',
|
||||
activityLevel: 'moderately_active',
|
||||
fitnessGoals: ['weight_loss'],
|
||||
exerciseHabits: 'None',
|
||||
dietHabits: 'None',
|
||||
medicalConditions: 'None'
|
||||
});
|
||||
console.log('Fitness profile created for:', profile.userId);
|
||||
|
||||
// 4. Attendance Check-in
|
||||
console.log('Checking in...');
|
||||
const checkIn = await db.checkIn(user.id, 'gym', 'Test check-in');
|
||||
console.log('Checked in:', checkIn.id);
|
||||
|
||||
// 5. Verify Active Check-in
|
||||
const activeCheckIn = await db.getActiveCheckIn(user.id);
|
||||
if (!activeCheckIn || activeCheckIn.id !== checkIn.id) {
|
||||
throw new Error('Active check-in verification failed');
|
||||
}
|
||||
console.log('Active check-in verified');
|
||||
|
||||
// 6. Attendance Check-out
|
||||
console.log('Checking out...');
|
||||
const checkOut = await db.checkOut(checkIn.id);
|
||||
console.log('Checked out:', checkOut?.checkOutTime);
|
||||
|
||||
// 7. Cleanup
|
||||
console.log('Cleaning up...');
|
||||
await db.deleteUser(user.id);
|
||||
console.log('Cleanup complete');
|
||||
|
||||
console.log('✅ Verification successful!');
|
||||
} catch (error) {
|
||||
console.error('❌ Verification failed:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await db.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
verifyDatabase();
|
||||
@@ -0,0 +1,47 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase, DatabaseFactory } from '@/lib/database'
|
||||
import { ensureUserSynced } from '@/lib/sync-user'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const BACKUP_DIR = path.join(process.cwd(), 'backups')
|
||||
const DB_PATH = path.join(process.cwd(), 'data', 'fitai.db')
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
const user = await ensureUserSynced(userId, db)
|
||||
|
||||
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const { filename } = await req.json()
|
||||
if (!filename) {
|
||||
return new NextResponse('Filename is required', { status: 400 })
|
||||
}
|
||||
|
||||
const backupPath = path.join(BACKUP_DIR, filename)
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
return new NextResponse('Backup file not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Close existing connection
|
||||
await DatabaseFactory.disconnect()
|
||||
|
||||
// Restore file
|
||||
fs.copyFileSync(backupPath, DB_PATH)
|
||||
|
||||
// Re-initialize connection (will happen automatically on next getDatabase call, but good to verify)
|
||||
await getDatabase()
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Restore backup error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { ensureUserSynced } from '@/lib/sync-user'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const BACKUP_DIR = path.join(process.cwd(), 'backups')
|
||||
const DB_PATH = path.join(process.cwd(), 'data', 'fitai.db')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
const user = await ensureUserSynced(userId, db)
|
||||
|
||||
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(BACKUP_DIR)
|
||||
.filter(file => file.endsWith('.db'))
|
||||
.map(file => {
|
||||
const stats = fs.statSync(path.join(BACKUP_DIR, file))
|
||||
return {
|
||||
name: file,
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
|
||||
return NextResponse.json(files)
|
||||
} catch (error) {
|
||||
console.error('List backups error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
const user = await ensureUserSynced(userId, db)
|
||||
|
||||
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const backupPath = path.join(BACKUP_DIR, `backup-${timestamp}.db`)
|
||||
|
||||
// Ensure source DB exists
|
||||
if (!fs.existsSync(DB_PATH)) {
|
||||
return new NextResponse('Database file not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Copy file
|
||||
fs.copyFileSync(DB_PATH, backupPath)
|
||||
|
||||
return NextResponse.json({ success: true, filename: `backup-${timestamp}.db` })
|
||||
} catch (error) {
|
||||
console.error('Create backup error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
|
||||
/**
|
||||
* GET /api/admin/clients
|
||||
*
|
||||
* Admin:
|
||||
* - Lists clients scoped to the admin's gym (requires admin.gymId).
|
||||
*
|
||||
* SuperAdmin:
|
||||
* - Optional query param ?gymId=<id> to filter clients by a specific gym.
|
||||
* - If no gymId provided, returns all clients across all gyms.
|
||||
*
|
||||
* Response: Array of client users with minimal fields for listing, including membership data.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const user = await ensureUserSynced(userId, db);
|
||||
|
||||
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const requestedGymId = url.searchParams.get("gymId");
|
||||
|
||||
// Admins must have a gymId; scope to their gym
|
||||
let targetGymId: string | null = null;
|
||||
if (user.role === "admin") {
|
||||
if (!user.gymId) {
|
||||
return new NextResponse("Admin gymId not set", { status: 400 });
|
||||
}
|
||||
targetGymId = user.gymId;
|
||||
} else if (user.role === "superAdmin") {
|
||||
targetGymId = requestedGymId;
|
||||
}
|
||||
|
||||
// Fetch users and clients
|
||||
const allUsers = await db.getAllUsers();
|
||||
const usersById = new Map(allUsers.map((u) => [u.id, u]));
|
||||
const allClients = await db.getAllClients();
|
||||
|
||||
// Scope clients by gym when provided
|
||||
const scopedClients = targetGymId
|
||||
? allClients.filter((c) => {
|
||||
const u = usersById.get(c.userId);
|
||||
return u?.gymId === targetGymId;
|
||||
})
|
||||
: allClients;
|
||||
|
||||
// Compose payload merging user and client info
|
||||
const payload = scopedClients.map((c) => {
|
||||
const u = usersById.get(c.userId);
|
||||
return {
|
||||
id: c.id,
|
||||
userId: c.userId,
|
||||
email: u?.email ?? null,
|
||||
firstName: u?.firstName ?? null,
|
||||
lastName: u?.lastName ?? null,
|
||||
gymId: u?.gymId ?? null,
|
||||
membershipType: c.membershipType,
|
||||
membershipStatus: c.membershipStatus,
|
||||
joinDate: c.joinDate,
|
||||
lastVisit: c.lastVisit ?? null,
|
||||
emergencyContact: c.emergencyContact ?? null,
|
||||
createdAt: u?.createdAt ?? null,
|
||||
updatedAt: u?.updatedAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("GET /api/admin/clients error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
|
||||
/**
|
||||
* POST /api/admin/set-user-metadata
|
||||
*
|
||||
* Sets Clerk publicMetadata.role and publicMetadata.gymId for a target user.
|
||||
*
|
||||
* Authorization:
|
||||
* - Caller must be an admin or superAdmin (based on their Clerk publicMetadata.role).
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* "targetUserId": string, // Clerk user ID of the target
|
||||
* "role": "superAdmin" | "admin" | "trainer" | "client" | "generalUser", // optional
|
||||
* "gymId": string | null // optional; null clears gym assignment
|
||||
* }
|
||||
*
|
||||
* Behavior:
|
||||
* - If "role" is provided, update the target user's publicMetadata.role.
|
||||
* - If "gymId" is provided (including null), update publicMetadata.gymId.
|
||||
* - Validates inputs and permissions.
|
||||
*
|
||||
* Response:
|
||||
* - 200 with updated minimal user data
|
||||
* - 400/401/403/404/500 on errors
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Authenticate the requester
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const client = await clerkClient();
|
||||
|
||||
// Fetch requester from Clerk to verify permissions
|
||||
const requester = await client.users.getUser(userId);
|
||||
const requesterRole =
|
||||
(requester.publicMetadata?.role as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser") ?? "client";
|
||||
|
||||
// Only admin or superAdmin can set metadata
|
||||
if (requesterRole !== "admin" && requesterRole !== "superAdmin") {
|
||||
return NextResponse.json(
|
||||
{ error: "Forbidden: admin or superAdmin access required" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse body
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON body" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const targetUserId = typeof body.targetUserId === "string" ? body.targetUserId : null;
|
||||
const role = body.role as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser"
|
||||
| undefined;
|
||||
const gymId =
|
||||
body.gymId === null
|
||||
? null
|
||||
: typeof body.gymId === "string"
|
||||
? body.gymId
|
||||
: undefined;
|
||||
|
||||
if (!targetUserId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or missing targetUserId" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate role if provided
|
||||
const allowedRoles = ["superAdmin", "admin", "trainer", "client", "generalUser"] as const;
|
||||
if (role && !allowedRoles.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role. Must be one of superAdmin, admin, trainer, client, generalUser" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent non-superAdmin from assigning superAdmin
|
||||
if (role === "superAdmin" && requesterRole !== "superAdmin") {
|
||||
return NextResponse.json(
|
||||
{ error: "Only superAdmin can assign superAdmin role" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch target user to ensure they exist
|
||||
let targetUser;
|
||||
try {
|
||||
targetUser = await client.users.getUser(targetUserId);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Target user not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Construct new metadata by merging with existing
|
||||
const newPublicMetadata: Record<string, unknown> = {
|
||||
...(targetUser.publicMetadata || {}),
|
||||
};
|
||||
|
||||
if (role !== undefined) {
|
||||
newPublicMetadata.role = role;
|
||||
}
|
||||
if (gymId !== undefined) {
|
||||
newPublicMetadata.gymId = gymId;
|
||||
}
|
||||
|
||||
// Ensure at least one field to update
|
||||
if (role === undefined && gymId === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Provide at least one of 'role' or 'gymId' to update" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Perform update on Clerk
|
||||
const updatedUser = await client.users.updateUser(targetUserId, {
|
||||
publicMetadata: newPublicMetadata,
|
||||
});
|
||||
|
||||
// Construct response payload
|
||||
const primaryEmail =
|
||||
updatedUser.emailAddresses?.find(
|
||||
(e) => e.id === updatedUser.primaryEmailAddressId
|
||||
)?.emailAddress || updatedUser.emailAddresses?.[0]?.emailAddress || null;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: true,
|
||||
message: "User metadata updated",
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: primaryEmail,
|
||||
firstName: updatedUser.firstName,
|
||||
lastName: updatedUser.lastName,
|
||||
role: updatedUser.publicMetadata?.role ?? null,
|
||||
gymId: updatedUser.publicMetadata?.gymId ?? null,
|
||||
},
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error?.errors?.[0]?.message ||
|
||||
error?.message ||
|
||||
"Internal server error";
|
||||
console.error("Error setting user metadata:", error);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,59 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { ensureUserSynced } from '@/lib/sync-user'
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
const db = await getDatabase()
|
||||
const user = await ensureUserSynced(userId, db)
|
||||
const db = await getDatabase();
|
||||
const user = await ensureUserSynced(userId, db);
|
||||
|
||||
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const stats = await db.getDashboardStats()
|
||||
|
||||
return NextResponse.json(stats)
|
||||
} catch (error) {
|
||||
console.error('Dashboard stats error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
if (user.role === "admin" && !user.gymId) {
|
||||
return new NextResponse("Admin gymId not set", { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
let targetGymId: string | null = null;
|
||||
if (user.role === "admin") {
|
||||
targetGymId = user.gymId ?? null;
|
||||
} else if (user.role === "superAdmin") {
|
||||
targetGymId = searchParams.get("gymId");
|
||||
}
|
||||
|
||||
const allUsers = await db.getAllUsers();
|
||||
const allClients = await db.getAllClients();
|
||||
|
||||
const usersById = new Map(allUsers.map((u) => [u.id, u]));
|
||||
const filteredUsers = targetGymId
|
||||
? allUsers.filter((u) => u.gymId === targetGymId)
|
||||
: allUsers;
|
||||
const filteredClients = targetGymId
|
||||
? allClients.filter((c) => {
|
||||
const u = usersById.get(c.userId);
|
||||
return u?.gymId === targetGymId;
|
||||
})
|
||||
: allClients;
|
||||
|
||||
const stats = {
|
||||
totalUsers: filteredUsers.length,
|
||||
activeClients: filteredClients.filter(
|
||||
(c) => c.membershipStatus === "active",
|
||||
).length,
|
||||
totalRevenue: 0,
|
||||
revenueGrowth: 0,
|
||||
};
|
||||
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
console.error("Dashboard stats error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
|
||||
/**
|
||||
* GET /api/admin/trainers
|
||||
*
|
||||
* Admin:
|
||||
* - Lists trainers scoped to the admin's gym (requires admin.gymId).
|
||||
*
|
||||
* SuperAdmin:
|
||||
* - Optional query param ?gymId=<id> to filter trainers by a specific gym.
|
||||
* - If no gymId provided, returns all trainers across all gyms.
|
||||
*
|
||||
* Response: Array of trainer users with minimal fields for listing
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const user = await ensureUserSynced(userId, db);
|
||||
|
||||
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const requestedGymId = url.searchParams.get("gymId");
|
||||
|
||||
// Admins must have a gymId; scope to their gym
|
||||
let targetGymId: string | null = null;
|
||||
if (user.role === "admin") {
|
||||
if (!user.gymId) {
|
||||
return new NextResponse("Admin gymId not set", { status: 400 });
|
||||
}
|
||||
targetGymId = user.gymId as string;
|
||||
} else if (user.role === "superAdmin") {
|
||||
targetGymId = requestedGymId;
|
||||
}
|
||||
|
||||
// Fetch all users and filter to trainers
|
||||
const allUsers = await db.getAllUsers();
|
||||
let trainers = allUsers.filter((u) => u.role === "trainer");
|
||||
|
||||
// Scope by gym when required/provided
|
||||
if (targetGymId) {
|
||||
trainers = trainers.filter((t) => t.gymId === targetGymId);
|
||||
}
|
||||
|
||||
// Minimal payload suitable for listing
|
||||
const payload = trainers.map((t) => ({
|
||||
id: t.id,
|
||||
email: t.email,
|
||||
firstName: t.firstName,
|
||||
lastName: t.lastName,
|
||||
gymId: t.gymId ?? null,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("GET /api/admin/trainers error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { POST as checkIn } from '../check-in/route'
|
||||
import { POST as checkOut } from '../check-out/route'
|
||||
import { GET as history } from '../history/route'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@clerk/nextjs/server', () => ({
|
||||
auth: jest.fn(() => Promise.resolve({ userId: 'test_user_id' })),
|
||||
currentUser: jest.fn(() => Promise.resolve({ id: 'test_user_id', emailAddresses: [{ emailAddress: 'test@example.com' }] }))
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/sync-user', () => ({
|
||||
ensureUserSynced: jest.fn()
|
||||
}))
|
||||
|
||||
const mockDb = {
|
||||
checkIn: jest.fn(),
|
||||
checkOut: jest.fn(),
|
||||
getAttendanceHistory: jest.fn(),
|
||||
getActiveCheckIn: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
createUser: jest.fn(),
|
||||
getClientByUserId: jest.fn(),
|
||||
createClient: jest.fn(),
|
||||
getFitnessProfileByUserId: jest.fn(),
|
||||
createFitnessProfile: jest.fn(),
|
||||
}
|
||||
|
||||
jest.mock('@/lib/database', () => ({
|
||||
getDatabase: jest.fn(() => Promise.resolve(mockDb))
|
||||
}))
|
||||
|
||||
describe('Attendance API', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('POST /api/attendance/check-in', () => {
|
||||
it('should successfully check in', async () => {
|
||||
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
|
||||
mockDb.getActiveCheckIn.mockResolvedValue(null)
|
||||
mockDb.checkIn.mockResolvedValue({
|
||||
id: 'attendance_id',
|
||||
userId: 'test_user_id',
|
||||
checkInTime: new Date(),
|
||||
type: 'gym'
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost/api/attendance/check-in', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'gym', notes: 'Test check-in' })
|
||||
})
|
||||
|
||||
const res = await checkIn(req)
|
||||
const data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.id).toBe('attendance_id')
|
||||
expect(data.userId).toBe('test_user_id')
|
||||
expect(mockDb.checkIn).toHaveBeenCalledWith('test_user_id', 'gym', 'Test check-in')
|
||||
})
|
||||
|
||||
it('should fail if already checked in', async () => {
|
||||
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
|
||||
mockDb.getActiveCheckIn.mockResolvedValue({ id: 'existing_id' })
|
||||
|
||||
const req = new NextRequest('http://localhost/api/attendance/check-in', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'gym' })
|
||||
})
|
||||
|
||||
const res = await checkIn(req)
|
||||
const text = await res.text()
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
expect(text).toBe('Already checked in')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/attendance/check-out', () => {
|
||||
it('should successfully check out', async () => {
|
||||
mockDb.getActiveCheckIn.mockResolvedValue({ id: 'attendance_id' })
|
||||
mockDb.checkOut.mockResolvedValue({
|
||||
id: 'attendance_id',
|
||||
checkOutTime: new Date()
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost/api/attendance/check-out', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
const res = await checkOut(req)
|
||||
const data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(data.id).toBe('attendance_id')
|
||||
expect(data.checkOutTime).toBeDefined()
|
||||
expect(mockDb.checkOut).toHaveBeenCalledWith('attendance_id')
|
||||
})
|
||||
|
||||
it('should fail if not checked in', async () => {
|
||||
mockDb.getActiveCheckIn.mockResolvedValue(null)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/attendance/check-out', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
const res = await checkOut(req)
|
||||
const text = await res.text()
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
expect(text).toBe('No active check-in found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/attendance/history', () => {
|
||||
it('should return attendance history', async () => {
|
||||
const historyData = [
|
||||
{ id: '1', checkInTime: new Date() },
|
||||
{ id: '2', checkInTime: new Date() }
|
||||
]
|
||||
mockDb.getUserById.mockResolvedValue({ id: 'test_user_id' })
|
||||
mockDb.getAttendanceHistory.mockResolvedValue(historyData)
|
||||
|
||||
const req = new NextRequest('http://localhost/api/attendance/history')
|
||||
const res = await history(req)
|
||||
const data = await res.json()
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(data).toEqual(JSON.parse(JSON.stringify(historyData))) // Handle date serialization
|
||||
expect(mockDb.getAttendanceHistory).toHaveBeenCalledWith('test_user_id')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,21 +13,8 @@ export async function POST(req: Request) {
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db)
|
||||
|
||||
let client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
// Auto-create client profile if it doesn't exist
|
||||
console.log('Client profile not found, creating new one for user:', userId)
|
||||
client = await db.createClient({
|
||||
userId,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
// Check if already checked in
|
||||
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
||||
const activeCheckIn = await db.getActiveCheckIn(userId)
|
||||
if (activeCheckIn) {
|
||||
return new NextResponse('Already checked in', { status: 400 })
|
||||
}
|
||||
@@ -35,7 +22,7 @@ export async function POST(req: Request) {
|
||||
const body = await req.json()
|
||||
const { type = 'gym', notes } = body
|
||||
|
||||
const attendance = await db.checkIn(client.id, type, notes)
|
||||
const attendance = await db.checkIn(userId, type, notes)
|
||||
return NextResponse.json(attendance)
|
||||
} catch (error) {
|
||||
console.error('Check-in error:', error)
|
||||
|
||||
@@ -8,13 +8,8 @@ export async function POST(req: Request) {
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const db = await getDatabase()
|
||||
const client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
return new NextResponse('Client profile not found', { status: 404 })
|
||||
}
|
||||
|
||||
const activeCheckIn = await db.getActiveCheckIn(client.id)
|
||||
const activeCheckIn = await db.getActiveCheckIn(userId)
|
||||
if (!activeCheckIn) {
|
||||
return new NextResponse('No active check-in found', { status: 404 })
|
||||
}
|
||||
|
||||
@@ -22,20 +22,7 @@ export async function GET(req: Request) {
|
||||
// Ensure user exists in DB (sync from Clerk if needed)
|
||||
await ensureUserSynced(userId, db)
|
||||
|
||||
let client = await db.getClientByUserId(userId)
|
||||
|
||||
if (!client) {
|
||||
// Auto-create client profile if it doesn't exist
|
||||
console.log('Client profile not found, creating new one for user:', userId)
|
||||
client = await db.createClient({
|
||||
userId,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
const history = await db.getAttendanceHistory(client.id)
|
||||
const history = await db.getAttendanceHistory(userId)
|
||||
return NextResponse.json(history)
|
||||
} catch (error) {
|
||||
console.error('History error:', error)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getDatabase } from '@/lib/database';
|
||||
// POST - Mark goal as complete
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getDatabase } from '@/lib/database';
|
||||
// GET - Get specific goal
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
@@ -40,7 +40,7 @@ export async function GET(
|
||||
// PUT - Update goal
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
@@ -82,7 +82,7 @@ export async function PUT(
|
||||
// DELETE - Delete goal
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { eq, sql } from "@fitai/database";
|
||||
import { db, users as usersTable } from "@fitai/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
|
||||
async function ensureGymsTable() {
|
||||
await db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS gyms (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
location TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active',
|
||||
admin_user_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// GET /api/gyms
|
||||
// Lists active gyms for selection (grid)
|
||||
export async function GET() {
|
||||
try {
|
||||
await ensureGymsTable();
|
||||
const rows = await db.all(
|
||||
sql`SELECT * FROM gyms WHERE status = 'active' ORDER BY created_at DESC`,
|
||||
);
|
||||
|
||||
return NextResponse.json(rows);
|
||||
} catch (error) {
|
||||
console.error("GET /gyms error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/gyms
|
||||
// Create a gym. Allowed roles: superAdmin, admin.
|
||||
// - admin: can only create gyms for themselves (adminUserId = current user)
|
||||
// - superAdmin: can create for self or specify adminUserId
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
await ensureGymsTable();
|
||||
const { userId } = await auth();
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
// Ensure our local DB has the user synced (role, etc.)
|
||||
const currentUser = await ensureUserSynced(userId, {
|
||||
// minimal facade for ensureUserSynced to work: it expects an object implementing part of IDatabase
|
||||
getUserById: async (id: string) => {
|
||||
const row = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id))
|
||||
.get();
|
||||
return row
|
||||
? {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
password: row.password ?? "",
|
||||
phone: row.phone ?? undefined,
|
||||
role: row.role,
|
||||
imageUrl: undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
updateUser: async (id: string, updates: any) => {
|
||||
await db
|
||||
.update(usersTable)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(usersTable.id, id))
|
||||
.run();
|
||||
const row = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id))
|
||||
.get();
|
||||
return row
|
||||
? {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
password: row.password ?? "",
|
||||
phone: row.phone ?? undefined,
|
||||
role: row.role,
|
||||
imageUrl: undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
} as any);
|
||||
|
||||
if (
|
||||
!currentUser ||
|
||||
(currentUser.role !== "admin" && currentUser.role !== "superAdmin")
|
||||
) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body || typeof body !== "object") {
|
||||
return new NextResponse("Invalid JSON body", { status: 400 });
|
||||
}
|
||||
|
||||
const name = String(body.name ?? "").trim();
|
||||
const location = body.location ? String(body.location).trim() : null;
|
||||
let adminUserId: string | null = body.adminUserId
|
||||
? String(body.adminUserId)
|
||||
: null;
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Enforce admin ownership rules
|
||||
if (currentUser.role === "admin") {
|
||||
adminUserId = currentUser.id;
|
||||
} else if (currentUser.role === "superAdmin") {
|
||||
adminUserId = adminUserId || currentUser.id;
|
||||
}
|
||||
|
||||
// Basic check that adminUserId exists and is an admin or superAdmin
|
||||
const adminRow = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, adminUserId!))
|
||||
.get();
|
||||
if (
|
||||
!adminRow ||
|
||||
(adminRow.role !== "admin" && adminRow.role !== "superAdmin")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "adminUserId must reference an admin or superAdmin" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const id = generateId();
|
||||
const nowTs = Date.now();
|
||||
|
||||
await db.run(
|
||||
sql`INSERT INTO gyms (id, name, location, status, admin_user_id, created_at, updated_at)
|
||||
VALUES (${id}, ${name}, ${location ?? null}, 'active', ${adminUserId!}, ${nowTs}, ${nowTs})`,
|
||||
);
|
||||
|
||||
// Assign the admin to this gym immediately after creation
|
||||
await db.run(
|
||||
sql`UPDATE users SET gym_id = ${id}, updated_at = ${nowTs} WHERE id = ${adminUserId!}`,
|
||||
);
|
||||
|
||||
const created = await db.get(sql`SELECT * FROM gyms WHERE id = ${id}`);
|
||||
return NextResponse.json(created, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("POST /gyms error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
// Simple URL-safe id generator
|
||||
return (
|
||||
Math.random().toString(36).slice(2, 10) +
|
||||
Math.random().toString(36).slice(2, 10)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
|
||||
/**
|
||||
* POST /api/invitations
|
||||
*
|
||||
* Create a Clerk-managed invitation and store role/gym context in publicMetadata.
|
||||
* This endpoint does not implement invitation acceptance; Clerk’s acceptance link flow and webhooks will handle that.
|
||||
*
|
||||
* Body: {
|
||||
* inviteeEmail: string,
|
||||
* roleAssigned: 'trainer' | 'client' | 'admin'
|
||||
* }
|
||||
*
|
||||
* Rules:
|
||||
* - admin: can invite trainer or client; the gymId is taken from inviter's publicMetadata or user record.
|
||||
* - trainer: can invite client; the gymId is taken from inviter's publicMetadata or user record.
|
||||
* - superAdmin: can invite admin; requires `gymId` in body, or falls back to inviter's `gymId` if present.
|
||||
*
|
||||
* Returns Clerk invitation payload.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const inviteeEmail = String(body.inviteeEmail ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const roleAssigned = String(body.roleAssigned ?? "").trim() as
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "admin";
|
||||
let requestedGymId: string | null = body.gymId ? String(body.gymId) : null;
|
||||
|
||||
if (!inviteeEmail || !roleAssigned) {
|
||||
return NextResponse.json(
|
||||
{ error: "inviteeEmail and roleAssigned are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch inviter user from Clerk
|
||||
const client = await clerkClient();
|
||||
const inviter = await client.users.getUser(userId);
|
||||
const inviterRole =
|
||||
(inviter.publicMetadata?.role as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser") ?? "client";
|
||||
const inviterGymId =
|
||||
(inviter.publicMetadata?.gymId as string | undefined) ?? undefined;
|
||||
|
||||
// Enforce role-based rules and resolve target gymId for the invitation
|
||||
let gymIdForInvite: string | null = null;
|
||||
switch (inviterRole) {
|
||||
case "admin": {
|
||||
if (roleAssigned !== "trainer" && roleAssigned !== "client") {
|
||||
return NextResponse.json(
|
||||
{ error: "Admin can only invite trainer or client" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (!inviterGymId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Inviter admin must be assigned to a gym" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
gymIdForInvite = inviterGymId;
|
||||
break;
|
||||
}
|
||||
case "trainer": {
|
||||
if (roleAssigned !== "client") {
|
||||
return NextResponse.json(
|
||||
{ error: "Trainer can only invite client" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (!inviterGymId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Inviter trainer must be assigned to a gym" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
gymIdForInvite = inviterGymId;
|
||||
break;
|
||||
}
|
||||
case "superAdmin": {
|
||||
if (
|
||||
roleAssigned !== "admin" &&
|
||||
roleAssigned !== "trainer" &&
|
||||
roleAssigned !== "client"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid roleAssigned for SuperAdmin" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
// Prefer explicitly provided gymId, otherwise fall back to inviter's gymId if present
|
||||
gymIdForInvite = requestedGymId || inviterGymId || null;
|
||||
if (!gymIdForInvite) {
|
||||
return NextResponse.json(
|
||||
{ error: "gymId is required for SuperAdmin when inviting" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return NextResponse.json(
|
||||
{ error: "Inviter role not permitted to create invitations" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Clerk invitation with metadata needed by webhook to assign role & gym
|
||||
// reuse existing Clerk client instance
|
||||
const invitation = await client.invitations.createInvitation({
|
||||
emailAddress: inviteeEmail,
|
||||
publicMetadata: {
|
||||
roleAssigned,
|
||||
gymId: gymIdForInvite,
|
||||
inviterUserId: inviter.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(invitation, { status: 201 });
|
||||
} catch (error: any) {
|
||||
// Surface Clerk errors where possible
|
||||
const message =
|
||||
error?.errors?.[0]?.message ||
|
||||
error?.message ||
|
||||
"Failed to create invitation";
|
||||
console.error("POST /api/invitations error:", error);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -172,9 +172,9 @@ export async function POST(req: Request) {
|
||||
const recommendation = await db.createRecommendation({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
fitnessProfileId: profile.userId,
|
||||
fitnessProfileId: profile.id,
|
||||
type: 'ai_plan',
|
||||
content: parsedResponse.recommendationText,
|
||||
recommendationText: parsedResponse.recommendationText,
|
||||
activityPlan: parsedResponse.activityPlan,
|
||||
dietPlan: parsedResponse.dietPlan,
|
||||
status: 'pending'
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function POST(request: Request) {
|
||||
userId,
|
||||
fitnessProfileId,
|
||||
type: 'ai_plan',
|
||||
content: recommendationText,
|
||||
recommendationText: recommendationText,
|
||||
activityPlan,
|
||||
dietPlan,
|
||||
status: status || 'pending'
|
||||
@@ -90,7 +90,7 @@ export async function POST(request: Request) {
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
type,
|
||||
content,
|
||||
recommendationText: content,
|
||||
status: status || 'pending'
|
||||
})
|
||||
return NextResponse.json(recommendation)
|
||||
@@ -122,8 +122,8 @@ export async function PUT(request: Request) {
|
||||
|
||||
const updated = await db.updateRecommendation(id, {
|
||||
...(status && { status }),
|
||||
...(recommendationText && { content: recommendationText }), // Map legacy field
|
||||
...(content && { content }),
|
||||
...(recommendationText && { recommendationText }),
|
||||
...(content && { recommendationText: content }),
|
||||
...(activityPlan && { activityPlan }),
|
||||
...(dietPlan && { dietPlan })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { db, users as usersTable, eq, sql } from "@fitai/database";
|
||||
|
||||
/**
|
||||
* PATCH /api/users/gym
|
||||
* Body: { gymId: string | null }
|
||||
* - Updates the current authenticated user's gym selection.
|
||||
* - gymId can be null to proceed without a gym.
|
||||
* - If gymId is provided, it must exist and be active.
|
||||
*/
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
console.log("PATCH /api/users/gym auth userId:", userId);
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body || typeof body !== "object" || !("gymId" in body)) {
|
||||
return NextResponse.json(
|
||||
{ error: "gymId is required in body (can be null)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const gymId = body.gymId === null ? null : String(body.gymId);
|
||||
console.log("PATCH /api/users/gym parsed gymId from body:", gymId);
|
||||
|
||||
// Ensure user exists
|
||||
console.log("PATCH /api/users/gym fetching user by id:", userId);
|
||||
const user = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.get();
|
||||
console.log("PATCH /api/users/gym fetched user:", user);
|
||||
if (!user) return new NextResponse("User not found", { status: 404 });
|
||||
|
||||
// Validate gym when provided
|
||||
if (gymId) {
|
||||
console.log("PATCH /api/users/gym validating gym:", gymId);
|
||||
const rows = await db.all(
|
||||
sql`SELECT status FROM gyms WHERE id = ${gymId} LIMIT 1`,
|
||||
);
|
||||
console.log("PATCH /api/users/gym validation query result rows:", rows);
|
||||
const gym = rows?.[0] as { status?: string } | undefined;
|
||||
if (!gym) {
|
||||
console.log("PATCH /api/users/gym validation: gym not found");
|
||||
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
|
||||
}
|
||||
if (gym.status !== "active") {
|
||||
console.log("PATCH /api/users/gym validation: gym not active", gym);
|
||||
return NextResponse.json(
|
||||
{ error: "Gym is not active" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update user's gym selection
|
||||
console.log("PATCH /api/users/gym updating user gym_id:", {
|
||||
userId,
|
||||
gymId,
|
||||
});
|
||||
await db.run(
|
||||
sql`UPDATE users SET gym_id = ${gymId ?? null}, updated_at = ${new Date()} WHERE id = ${userId}`,
|
||||
);
|
||||
console.log("PATCH /api/users/gym update completed");
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.get();
|
||||
console.log("PATCH /api/users/gym returning updated user:", updated);
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
console.error("PATCH /users/gym error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "../../../lib/database/index";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
import { db as rawDb, sql } from "@fitai/database";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -11,18 +12,122 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
let users = await db.getAllUsers();
|
||||
|
||||
// Hydrate gymId from raw DB to ensure consistency with writes
|
||||
const rawUserRows = await rawDb.all(sql`SELECT id, gym_id FROM users`);
|
||||
const gymById = new Map<string, string | null>(
|
||||
(rawUserRows || []).map((r: any) => [
|
||||
r.id as string,
|
||||
(r.gym_id as string | null) ?? null,
|
||||
]),
|
||||
);
|
||||
|
||||
// Load gym names for mapping gymId -> gymName
|
||||
const gymRows = await rawDb.all(sql`SELECT id, name FROM gyms`);
|
||||
const gymNames = new Map<string, string>(
|
||||
(gymRows || [])
|
||||
.filter((g: any) => !!g && typeof g.id === "string")
|
||||
.map((g: any) => [
|
||||
g.id as string,
|
||||
(g.name as string) || (g.id as string),
|
||||
]),
|
||||
);
|
||||
console.log(
|
||||
"GET /api/users: total users fetched from DB:",
|
||||
Array.isArray(users) ? users.length : 0,
|
||||
);
|
||||
|
||||
if (role) {
|
||||
users = users.filter((user) => user.role === role);
|
||||
}
|
||||
console.log(
|
||||
"GET /api/users: role filter:",
|
||||
role,
|
||||
"users after filter:",
|
||||
Array.isArray(users) ? users.length : 0,
|
||||
"sample:",
|
||||
users && users[0]
|
||||
? {
|
||||
id: users[0].id,
|
||||
role: users[0].role,
|
||||
gymId: (users as any)[0].gymId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
const usersWithClients = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
const client = await db.getClientByUserId(user.id);
|
||||
return { ...userWithoutPassword, client };
|
||||
|
||||
// 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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(
|
||||
"GET /api/users: responding users count:",
|
||||
Array.isArray(usersWithClients) ? usersWithClients.length : 0,
|
||||
"sample:",
|
||||
usersWithClients && usersWithClients[0]
|
||||
? {
|
||||
id: usersWithClients[0].id,
|
||||
role: usersWithClients[0].role,
|
||||
gymId: (usersWithClients as any)[0].gymId,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
return NextResponse.json({ users: usersWithClients });
|
||||
} catch (error) {
|
||||
console.error("Get users error:", error);
|
||||
@@ -43,12 +148,12 @@ export async function POST(request: NextRequest) {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Get current user to check role
|
||||
// Note: In a real app, we'd map Clerk ID to our DB ID.
|
||||
// Note: In a real app, we'd map Clerk ID to our DB ID.
|
||||
// For now, we'll assume we can find the user by some means or trust the Clerk metadata if we synced it.
|
||||
// Since we don't have Clerk ID in our local DB users table yet (we only have our own ID),
|
||||
// we might need to rely on the user being synced.
|
||||
// Let's assume the user calling this API is already in our DB.
|
||||
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
|
||||
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
|
||||
// OR we'll assume the first user is Super Admin if no users exist?
|
||||
// Actually, we should look up the user by email if we can't by ID, or add a clerkId column.
|
||||
// For this step, let's assume we can get the user.
|
||||
@@ -60,7 +165,10 @@ 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 NextResponse.json(
|
||||
{ error: "Current user not found in database" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
@@ -78,14 +186,14 @@ export async function POST(request: NextRequest) {
|
||||
superAdmin: ["admin", "trainer", "client"],
|
||||
admin: ["trainer", "client"],
|
||||
trainer: ["client"],
|
||||
client: []
|
||||
client: [],
|
||||
};
|
||||
|
||||
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 }
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,20 +215,24 @@ export async function POST(request: NextRequest) {
|
||||
publicMetadata: {
|
||||
role,
|
||||
},
|
||||
ignoreExisting: true // Don't fail if invite exists
|
||||
ignoreExisting: true, // Don't fail if invite exists
|
||||
});
|
||||
} catch (clerkError: any) {
|
||||
console.error("Clerk invitation error:", clerkError);
|
||||
// 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') {
|
||||
if (clerkError.errors?.[0]?.code === "form_identifier_exists") {
|
||||
return NextResponse.json(
|
||||
{ error: "User already exists in Clerk system" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send invitation: " + (clerkError.message || "Unknown error") },
|
||||
{
|
||||
error:
|
||||
"Failed to send invitation: " +
|
||||
(clerkError.message || "Unknown error"),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
@@ -137,16 +249,19 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
// If creating a client, create the client record too
|
||||
if (role === 'client') {
|
||||
if (role === "client") {
|
||||
await db.createClient({
|
||||
userId: newUserId.id,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ userId: newUserId.id, message: "Invitation sent" }, { status: 201 });
|
||||
return NextResponse.json(
|
||||
{ userId: newUserId.id, message: "Invitation sent" },
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Create user error:", error);
|
||||
return NextResponse.json(
|
||||
@@ -160,7 +275,16 @@ export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const body = await request.json();
|
||||
const { id, email, firstName, lastName, role, phone } = body;
|
||||
const { id, email, firstName, lastName, role, phone, gymId } = body;
|
||||
console.log("PUT /api/users received body:", {
|
||||
id,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
role,
|
||||
phone,
|
||||
gymId,
|
||||
});
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@@ -169,12 +293,43 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get existing user
|
||||
// Authenticate requester
|
||||
const { userId: requesterId } = await auth();
|
||||
if (!requesterId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch requester and target user
|
||||
const requester = await db.getUserById(requesterId);
|
||||
if (!requester) {
|
||||
return NextResponse.json(
|
||||
{ error: "Requester not found" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await db.getUserById(id);
|
||||
if (!existingUser) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Authorization: determine allowed role changes
|
||||
const requesterRole = requester.role;
|
||||
const allowedByRole: Record<string, string[]> = {
|
||||
superAdmin: ["superAdmin", "admin", "trainer", "client", "generalUser"],
|
||||
admin: ["admin", "trainer", "client", "generalUser"],
|
||||
trainer: [], // trainers cannot change roles
|
||||
client: [], // clients cannot change roles
|
||||
generalUser: [], // general users cannot change roles
|
||||
};
|
||||
|
||||
if (role && !allowedByRole[requesterRole]?.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Not authorized to assign role '${role}'` },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email is being changed and if it's already taken
|
||||
if (email && email !== existingUser.email) {
|
||||
const userWithEmail = await db.getUserByEmail(email);
|
||||
@@ -186,16 +341,86 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
await db.updateUser(id, {
|
||||
email: email || existingUser.email,
|
||||
firstName: firstName || existingUser.firstName,
|
||||
lastName: lastName || existingUser.lastName,
|
||||
role: role || existingUser.role,
|
||||
phone: phone !== undefined ? phone : existingUser.phone,
|
||||
});
|
||||
// Update Clerk publicMetadata (role/gymId) to propagate via webhook
|
||||
// Note: Only update metadata when a change is requested
|
||||
try {
|
||||
const client = await clerkClient();
|
||||
const publicMetadata: Record<string, unknown> = {};
|
||||
console.log("PUT /api/users preparing Clerk metadata update:", {
|
||||
targetUserId: id,
|
||||
role,
|
||||
gymId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
if (role) {
|
||||
publicMetadata.role = role;
|
||||
}
|
||||
if (gymId !== undefined) {
|
||||
publicMetadata.gymId = gymId === null ? null : String(gymId);
|
||||
}
|
||||
|
||||
if (Object.keys(publicMetadata).length > 0) {
|
||||
console.log(
|
||||
"PUT /api/users calling Clerk updateUser with metadata:",
|
||||
publicMetadata,
|
||||
);
|
||||
const clerkResult = await client.users.updateUser(id, {
|
||||
publicMetadata,
|
||||
});
|
||||
console.log("PUT /api/users Clerk updateUser result:", {
|
||||
id: clerkResult.id,
|
||||
role: clerkResult.publicMetadata?.role,
|
||||
gymId: clerkResult.publicMetadata?.gymId,
|
||||
});
|
||||
} else {
|
||||
console.log("PUT /api/users no Clerk metadata changes requested");
|
||||
}
|
||||
} catch (clerkErr: any) {
|
||||
console.error("Clerk metadata update error:", clerkErr);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update role/gym in identity provider" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update local DB for immediate UI feedback (webhook will also sync)
|
||||
console.log(
|
||||
"PUT /api/users raw SQL updating local DB user gym_id and fields",
|
||||
);
|
||||
await rawDb.run(
|
||||
sql`UPDATE users
|
||||
SET email = ${email ?? existingUser.email},
|
||||
first_name = ${firstName ?? existingUser.firstName},
|
||||
last_name = ${lastName ?? existingUser.lastName},
|
||||
role = ${role ?? existingUser.role},
|
||||
phone = ${phone !== undefined && typeof phone === "string" ? phone : (existingUser.phone ?? null)},
|
||||
gym_id = ${gymId !== undefined ? gymId : (existingUser.gymId ?? null)},
|
||||
updated_at = ${Date.now()}
|
||||
WHERE id = ${id}`,
|
||||
);
|
||||
// Read back the updated row to surface gym_id and confirm write
|
||||
const updatedRow = await rawDb.get(
|
||||
sql`SELECT id, email, first_name, last_name, role, phone, gym_id, created_at, updated_at FROM users WHERE id = ${id}`,
|
||||
);
|
||||
console.log("PUT /api/users raw DB row after update:", updatedRow);
|
||||
|
||||
const updatedUser = {
|
||||
...existingUser,
|
||||
email: email ?? existingUser.email,
|
||||
firstName: firstName ?? existingUser.firstName,
|
||||
lastName: lastName ?? existingUser.lastName,
|
||||
role: role ?? existingUser.role,
|
||||
phone: phone !== undefined ? phone : existingUser.phone,
|
||||
gymId:
|
||||
updatedRow?.gym_id !== undefined
|
||||
? updatedRow.gym_id
|
||||
: gymId !== undefined
|
||||
? gymId
|
||||
: existingUser.gymId,
|
||||
};
|
||||
console.log("PUT /api/users responding with updated user:", updatedUser);
|
||||
|
||||
return NextResponse.json({ user: updatedUser });
|
||||
} catch (error) {
|
||||
console.error("Update user error:", error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -78,15 +78,31 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine role from metadata or default to 'client'
|
||||
// Determine role & gym from metadata
|
||||
const role =
|
||||
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
|
||||
(public_metadata?.role as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser") || "client";
|
||||
let gymId = (public_metadata?.gymId as string | null) ?? null;
|
||||
const inviterUserId =
|
||||
(public_metadata?.inviterUserId as string | undefined) ?? undefined;
|
||||
const roleAssigned =
|
||||
(public_metadata?.roleAssigned as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser"
|
||||
| undefined) ?? role;
|
||||
|
||||
// Insert user into database with Clerk's user ID
|
||||
const now = new Date().toISOString();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, email, first_name, last_name, password, phone, role, gym_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
@@ -97,11 +113,57 @@ export async function POST(req: Request) {
|
||||
"", // Clerk handles authentication
|
||||
null, // phone
|
||||
role,
|
||||
gymId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
console.log(`✅ User ${id} created in database`);
|
||||
// If this is a client invited by a trainer, create trainer-client link
|
||||
if (roleAssigned === "client" && inviterUserId && gymId) {
|
||||
const inviterRow = db
|
||||
.prepare("SELECT role FROM users WHERE id = ?")
|
||||
.get(inviterUserId) as { role?: string } | undefined;
|
||||
|
||||
if (inviterRow?.role === "trainer") {
|
||||
const linkId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const linkStmt = db.prepare(`
|
||||
INSERT INTO trainer_clients (id, trainer_user_id, client_user_id, gym_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
linkStmt.run(
|
||||
linkId,
|
||||
inviterUserId,
|
||||
id,
|
||||
gymId,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a trainer without a gymId but has an inviter, inherit inviter's gymId
|
||||
if (
|
||||
(roleAssigned === "trainer" || role === "trainer") &&
|
||||
!gymId &&
|
||||
inviterUserId
|
||||
) {
|
||||
const inviterGymRow = db
|
||||
.prepare("SELECT gym_id FROM users WHERE id = ?")
|
||||
.get(inviterUserId) as { gym_id?: string } | undefined;
|
||||
|
||||
if (inviterGymRow?.gym_id) {
|
||||
const inheritStmt = db.prepare(`
|
||||
UPDATE users
|
||||
SET gym_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
|
||||
gymId = inviterGymRow.gym_id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ User ${id} created in database (role=${role}, gymId=${gymId ?? "null"})`,
|
||||
);
|
||||
db.close();
|
||||
break;
|
||||
}
|
||||
@@ -124,15 +186,21 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine role from metadata
|
||||
// Determine role & gym from metadata
|
||||
const role =
|
||||
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
|
||||
(public_metadata?.role as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser") || "client";
|
||||
let gymId = (public_metadata?.gymId as string | null) ?? null;
|
||||
|
||||
// Update user in database
|
||||
const now = new Date().toISOString();
|
||||
const stmt = db.prepare(`
|
||||
UPDATE users
|
||||
SET email = ?, firstName = ?, lastName = ?, role = ?, updatedAt = ?
|
||||
SET email = ?, first_name = ?, last_name = ?, role = ?, gym_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
@@ -141,10 +209,33 @@ export async function POST(req: Request) {
|
||||
first_name || "",
|
||||
last_name || "",
|
||||
role,
|
||||
gymId,
|
||||
now,
|
||||
id,
|
||||
);
|
||||
|
||||
// If user is a trainer and gymId is missing, attempt to inherit from inviter when available
|
||||
if (
|
||||
role === "trainer" &&
|
||||
!gymId &&
|
||||
evt.data.public_metadata?.inviterUserId
|
||||
) {
|
||||
const inviterUserId = String(evt.data.public_metadata.inviterUserId);
|
||||
const inviterGymRow = db
|
||||
.prepare("SELECT gym_id FROM users WHERE id = ?")
|
||||
.get(inviterUserId) as { gym_id?: string } | undefined;
|
||||
|
||||
if (inviterGymRow?.gym_id) {
|
||||
const inheritStmt = db.prepare(`
|
||||
UPDATE users
|
||||
SET gym_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
|
||||
gymId = inviterGymRow.gym_id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ User ${id} updated in database`);
|
||||
db.close();
|
||||
break;
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function RootLayout({
|
||||
<body className={inter.className}>
|
||||
<div className="flex min-h-screen bg-slate-50">
|
||||
<Sidebar />
|
||||
<main className="flex-1 ml-64 p-8">
|
||||
<main className="flex-1 ml-20 p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -87,12 +87,11 @@ export default function Home() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-6">Recent Activity</h3>
|
||||
<UserManagement />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-6">Quick Analytics</h3>
|
||||
<AnalyticsDashboard />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface UserProfile {
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function RecommendationsPage() {
|
||||
</div>
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<div>
|
||||
<span className="font-semibold">Advice:</span> {rec.content}
|
||||
<span className="font-semibold">Advice:</span> {rec.recommendationText}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Activity:</span> {rec.activityPlan}
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import {
|
||||
Database,
|
||||
Download,
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
|
||||
interface Backup {
|
||||
name: string;
|
||||
size: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Gym {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string | null;
|
||||
status: "active" | "inactive";
|
||||
adminUserId: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useUser();
|
||||
const [backups, setBackups] = useState<Backup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creatingBackup, setCreatingBackup] = useState(false);
|
||||
const [restoring, setRestoring] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
// Gym picker state
|
||||
const [gyms, setGyms] = useState<Gym[]>([]);
|
||||
const [gymsLoading, setGymsLoading] = useState<boolean>(true);
|
||||
const [gymMessage, setGymMessage] = useState<{
|
||||
type: "success" | "error";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
// Create Gym modal state
|
||||
const [showCreateGym, setShowCreateGym] = useState(false);
|
||||
const [gymName, setGymName] = useState("");
|
||||
const [gymLocation, setGymLocation] = useState("");
|
||||
const [creatingGym, setCreatingGym] = useState(false);
|
||||
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
const response = await axios.get("/api/admin/backups");
|
||||
setBackups(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch backups:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGyms = async () => {
|
||||
setGymsLoading(true);
|
||||
setGymMessage(null);
|
||||
try {
|
||||
const res = await axios.get("/api/gyms");
|
||||
setGyms(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch gyms:", error);
|
||||
setGymMessage({ type: "error", text: "Failed to load gyms" });
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchBackups();
|
||||
fetchGyms();
|
||||
}, []);
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setCreatingBackup(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await axios.post("/api/admin/backups");
|
||||
await fetchBackups();
|
||||
setMessage({ type: "success", text: "Backup created successfully" });
|
||||
} catch (error) {
|
||||
console.error("Failed to create backup:", error);
|
||||
setMessage({ type: "error", text: "Failed to create backup" });
|
||||
} finally {
|
||||
setCreatingBackup(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async (filename: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Are you sure you want to restore from ${filename}? This will overwrite the current database.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoring(filename);
|
||||
setMessage(null);
|
||||
try {
|
||||
await axios.post("/api/admin/backups/restore", { filename });
|
||||
setMessage({ type: "success", text: "Database restored successfully" });
|
||||
// Optional: Refresh page or force re-login if session is invalidated
|
||||
} catch (error) {
|
||||
console.error("Failed to restore backup:", error);
|
||||
setMessage({ type: "error", text: "Failed to restore backup" });
|
||||
} finally {
|
||||
setRestoring(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const handleSelectGym = async (gymId: string | null) => {
|
||||
setGymMessage(null);
|
||||
try {
|
||||
// Update current user's gym selection
|
||||
await axios.patch("/api/users/gym", { gymId });
|
||||
setGymMessage({
|
||||
type: "success",
|
||||
text: gymId ? "Gym selected successfully" : "Proceeding without gym",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to set gym:", error);
|
||||
setGymMessage({ type: "error", text: "Failed to set gym" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-slate-900">Settings</h2>
|
||||
<p className="text-slate-500 mt-2">
|
||||
Manage your application settings and database.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gym Picker */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<Database className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900">
|
||||
Gym Selection
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Select your gym or proceed without a gym
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{user ? (
|
||||
<>
|
||||
Current role:{" "}
|
||||
<span className="font-medium">
|
||||
{String(user.publicMetadata?.role ?? "unknown")}
|
||||
</span>
|
||||
{" • "}
|
||||
Gym ID:{" "}
|
||||
<span className="font-medium">
|
||||
{String(user.publicMetadata?.gymId ?? "none")}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
"Loading user metadata..."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={fetchGyms}
|
||||
disabled={gymsLoading}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{gymsLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
Refresh Gyms
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCreateGym(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Create Gym
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gymMessage && (
|
||||
<div
|
||||
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${gymMessage.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}
|
||||
>
|
||||
{gymMessage.type === "success" ? (
|
||||
<Check className="w-5 h-5" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
)}
|
||||
{gymMessage.text}
|
||||
</div>
|
||||
)}
|
||||
{showCreateGym && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
|
||||
<h3 className="text-lg font-semibold mb-4">Create Gym</h3>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setCreatingGym(true);
|
||||
await axios.post("/api/gyms", {
|
||||
name: gymName.trim(),
|
||||
location: gymLocation.trim() || undefined,
|
||||
});
|
||||
setGymMessage({
|
||||
type: "success",
|
||||
text: "Gym created successfully",
|
||||
});
|
||||
setShowCreateGym(false);
|
||||
setGymName("");
|
||||
setGymLocation("");
|
||||
fetchGyms();
|
||||
} catch (error) {
|
||||
console.error("Failed to create gym:", error);
|
||||
setGymMessage({
|
||||
type: "error",
|
||||
text: "Failed to create gym",
|
||||
});
|
||||
} finally {
|
||||
setCreatingGym(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Gym Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={gymName}
|
||||
onChange={(e) => setGymName(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Location (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={gymLocation}
|
||||
onChange={(e) => setGymLocation(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Enter location"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateGym(false);
|
||||
setGymName("");
|
||||
setGymLocation("");
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creatingGym}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
{creatingGym ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : null}
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="border rounded-lg p-4 flex flex-col justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">
|
||||
Proceed without gym
|
||||
</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
You can select a gym later.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => handleSelectGym(null)}
|
||||
>
|
||||
Proceed without gym
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{gymsLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center p-8 text-slate-500">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
Loading gyms...
|
||||
</div>
|
||||
) : gyms.length === 0 ? (
|
||||
<div className="col-span-full p-8 text-center text-slate-500">
|
||||
No active gyms found.
|
||||
</div>
|
||||
) : (
|
||||
gyms.map((gym) => (
|
||||
<div
|
||||
key={gym.id}
|
||||
className="border rounded-lg p-4 flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">{gym.name}</h4>
|
||||
<p className="text-sm text-slate-600 mt-1">
|
||||
{gym.location || "No location provided"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
className="mt-4"
|
||||
onClick={() => handleSelectGym(gym.id)}
|
||||
>
|
||||
Select this gym
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<Database className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900">
|
||||
Database Management
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Create backups and restore your database
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateBackup}
|
||||
disabled={creatingBackup}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{creatingBackup ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="w-4 h-4" />
|
||||
)}
|
||||
Create Backup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${
|
||||
message.type === "success"
|
||||
? "bg-green-50 text-green-700"
|
||||
: "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{message.type === "success" ? (
|
||||
<Check className="w-5 h-5" />
|
||||
) : (
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
)}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th className="px-6 py-4 font-semibold text-slate-900">
|
||||
Filename
|
||||
</th>
|
||||
<th className="px-6 py-4 font-semibold text-slate-900">Size</th>
|
||||
<th className="px-6 py-4 font-semibold text-slate-900">
|
||||
Created At
|
||||
</th>
|
||||
<th className="px-6 py-4 font-semibold text-slate-900 text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-6 py-8 text-center text-slate-500"
|
||||
>
|
||||
Loading backups...
|
||||
</td>
|
||||
</tr>
|
||||
) : backups.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={4}
|
||||
className="px-6 py-8 text-center text-slate-500"
|
||||
>
|
||||
No backups found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
backups.map((backup) => (
|
||||
<tr
|
||||
key={backup.name}
|
||||
className="hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 font-medium text-slate-900">
|
||||
{backup.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-600">
|
||||
{formatSize(backup.size)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-600">
|
||||
{formatDate(backup.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRestore(backup.name)}
|
||||
disabled={!!restoring}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
{restoring === backup.name ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Restore
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
import { type ReactElement } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Home, Users, BarChart3, User, Brain } from "lucide-react";
|
||||
import { Home, Users, BarChart3, User, Brain, Settings } from "lucide-react";
|
||||
import { SignedIn, UserButton } from "@clerk/nextjs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
@@ -40,6 +40,11 @@ const navItems: NavItem[] = [
|
||||
label: "Profile",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
href: "/settings",
|
||||
label: "Settings",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
export function Navigation(): ReactElement {
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AnalyticsDashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 w-full">
|
||||
<h2 className="text-2xl font-bold">Analytics Dashboard</h2>
|
||||
|
||||
{/* Key Metrics */}
|
||||
@@ -132,9 +132,15 @@ export function AnalyticsDashboard() {
|
||||
<h3 className="text-lg font-semibold">Monthly Revenue</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RevenueChart data={revenueData} />
|
||||
<RevenueChart
|
||||
data={revenueData.map(item => ({
|
||||
category: item.label,
|
||||
value: item.value,
|
||||
color: item.color
|
||||
}))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary'
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', children, className = '', ...props }: ButtonProps) {
|
||||
const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors'
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Card({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={`mb-4 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardContent({ children, className = '' }: CardProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,9 +65,9 @@ export function Sidebar() {
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800">
|
||||
<div className="p-6 border-b border-slate-800">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
|
||||
<aside className="w-20 hover:w-64 bg-slate-900 text-white h-screen fixed left-0 top-0 flex flex-col border-r border-slate-800 transition-all duration-300 z-50 group overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-800 flex items-center overflow-hidden whitespace-nowrap">
|
||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
FitAI Admin
|
||||
</h1>
|
||||
</div>
|
||||
@@ -81,21 +81,27 @@ export function Sidebar() {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group ${isActive
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 ${isActive
|
||||
? "bg-blue-600 text-white shadow-lg shadow-blue-900/20"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"}`}
|
||||
>
|
||||
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
|
||||
<span className="font-medium">{label}</span>
|
||||
<div className="min-w-[20px]">
|
||||
<Icon size={20} className={isActive ? "text-white" : "text-slate-500 group-hover:text-white"} />
|
||||
</div>
|
||||
<span className="font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-300 whitespace-nowrap overflow-hidden">
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-slate-800">
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-lg bg-slate-800/50">
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 px-2 py-3 rounded-lg bg-slate-800/50 overflow-hidden whitespace-nowrap">
|
||||
<div className="min-w-[32px]">
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{user?.fullName || "Admin User"}
|
||||
</p>
|
||||
|
||||
@@ -1,56 +1,49 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline' | 'default'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
isLoading?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'default',
|
||||
isLoading = false,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'
|
||||
|
||||
export { Button, buttonVariants };
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow hover:bg-blue-700/90',
|
||||
default: 'bg-blue-600 text-white hover:bg-blue-700 shadow hover:bg-blue-700/90',
|
||||
secondary: 'bg-slate-100 text-slate-900 hover:bg-slate-200/80',
|
||||
ghost: 'hover:bg-slate-100 hover:text-slate-900',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600/90',
|
||||
outline: 'border border-input bg-transparent shadow-sm hover:bg-slate-100 hover:text-slate-900'
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9'
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(baseClasses, variantClasses[variant], sizeClasses[size], className)}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
||||
content: string;
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: "pending" | "completed" | "approved" | "rejected";
|
||||
@@ -97,7 +97,7 @@ export function Recommendations({ userId }: RecommendationsProps) {
|
||||
}`}
|
||||
>
|
||||
<div className="w-full">
|
||||
<p className="text-sm font-medium">{rec.content}</p>
|
||||
<p className="text-gray-700">{rec.recommendationText}</p>
|
||||
{rec.type === 'ai_plan' && (
|
||||
<div className="mt-2 text-xs text-gray-600 space-y-1">
|
||||
{rec.activityPlan && <p><span className="font-semibold">Activity:</span> {rec.activityPlan}</p>}
|
||||
|
||||
@@ -9,6 +9,19 @@ import { formatDate } from "@/lib/utils";
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
function getTimeAgo(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -16,7 +29,11 @@ interface User {
|
||||
lastName: string;
|
||||
role: string;
|
||||
phone?: string;
|
||||
gymId?: string;
|
||||
gymName?: string | null;
|
||||
createdAt: Date;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: Date;
|
||||
client?: {
|
||||
id: string;
|
||||
membershipType: string;
|
||||
@@ -45,6 +62,31 @@ export function UserGrid({
|
||||
}: UserGridProps) {
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [gymNames, setGymNames] = useState<Record<string, string>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/gyms");
|
||||
const data = await res.json();
|
||||
if (isMounted && Array.isArray(data)) {
|
||||
const map: Record<string, string> = {};
|
||||
for (const g of data) {
|
||||
if (g && g.id) {
|
||||
map[g.id] = g.name || g.id;
|
||||
}
|
||||
}
|
||||
setGymNames(map);
|
||||
}
|
||||
} catch (e) {
|
||||
// silently fail; we'll show gymId if name not available
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const columnDefs: ColDef<User>[] = useMemo(
|
||||
() => [
|
||||
@@ -79,16 +121,36 @@ export function UserGrid({
|
||||
roleColors[params.value as keyof typeof roleColors] ||
|
||||
"bg-gray-100 text-gray-800";
|
||||
|
||||
const label = params.value === 'superAdmin' ? 'Super Admin' : params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||
const label =
|
||||
params.value === "superAdmin"
|
||||
? "Super Admin"
|
||||
: params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
headerName: "Gym",
|
||||
field: "gymId",
|
||||
filter: "agTextColumnFilter",
|
||||
sortable: true,
|
||||
minWidth: 160,
|
||||
valueFormatter: (params: any) => {
|
||||
const gymId = params.value;
|
||||
const gymName = params.data?.gymName;
|
||||
if (!gymId && !gymName) return "None";
|
||||
if (gymName) return gymName;
|
||||
return gymNames[gymId] || gymId;
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
headerName: "Phone",
|
||||
field: "phone",
|
||||
@@ -103,7 +165,8 @@ export function UserGrid({
|
||||
filter: "agTextColumnFilter",
|
||||
sortable: true,
|
||||
cellRenderer: (params: any) => {
|
||||
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
|
||||
if (!params.value || params.value === "N/A")
|
||||
return <span className="text-gray-400">N/A</span>;
|
||||
|
||||
const membershipColors = {
|
||||
vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||
@@ -114,10 +177,13 @@ export function UserGrid({
|
||||
membershipColors[params.value as keyof typeof membershipColors] ||
|
||||
"bg-gray-100 text-gray-800";
|
||||
|
||||
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||
const label =
|
||||
params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -130,7 +196,8 @@ export function UserGrid({
|
||||
filter: "agTextColumnFilter",
|
||||
sortable: true,
|
||||
cellRenderer: (params: any) => {
|
||||
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
|
||||
if (!params.value || params.value === "N/A")
|
||||
return <span className="text-gray-400">N/A</span>;
|
||||
|
||||
const statusColors = {
|
||||
active: "bg-green-100 text-green-800",
|
||||
@@ -142,16 +209,42 @@ export function UserGrid({
|
||||
statusColors[params.value as keyof typeof statusColors] ||
|
||||
"bg-gray-100 text-gray-800";
|
||||
|
||||
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||
const label =
|
||||
params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
headerName: "Currently Checked In",
|
||||
valueGetter: (params) => params.data?.isCheckedIn,
|
||||
filter: "agTextColumnFilter",
|
||||
sortable: true,
|
||||
cellRenderer: (params: any) => {
|
||||
if (!params.data?.isCheckedIn) {
|
||||
return <span className="text-gray-400">—</span>;
|
||||
}
|
||||
|
||||
const checkInTime = params.data.checkInTime
|
||||
? new Date(params.data.checkInTime)
|
||||
: null;
|
||||
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : "";
|
||||
|
||||
return (
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
|
||||
✓ Checked In {timeAgo && `(${timeAgo})`}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
headerName: "Join Date",
|
||||
valueGetter: (params) =>
|
||||
@@ -187,14 +280,16 @@ export function UserGrid({
|
||||
const gridRef = React.useRef<AgGridReact<User>>(null);
|
||||
|
||||
const gridOptions = {
|
||||
theme: "legacy",
|
||||
theme: "legacy" as const,
|
||||
columnDefs,
|
||||
defaultColDef,
|
||||
rowData: users,
|
||||
rowSelection: "multiple",
|
||||
rowSelection: "multiple" as const,
|
||||
onSelectionChanged: () => {
|
||||
const selectedNodes = gridRef.current?.api.getSelectedNodes();
|
||||
const selectedData = selectedNodes?.map((node) => node.data) || [];
|
||||
const selectedData =
|
||||
selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) ||
|
||||
[];
|
||||
setSelectedUsers(selectedData);
|
||||
if (selectedData.length === 1 && onUserSelect) {
|
||||
onUserSelect(selectedData[0]);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { UserGrid } from "@/components/users/UserGrid";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
// import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -12,7 +14,13 @@ interface User {
|
||||
lastName: string;
|
||||
role: string;
|
||||
phone?: string;
|
||||
gymId?: string;
|
||||
createdAt: Date;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: Date;
|
||||
lastCheckInTime?: Date;
|
||||
checkInsThisWeek?: number;
|
||||
checkInsThisMonth?: number;
|
||||
client?: {
|
||||
id: string;
|
||||
membershipType: string;
|
||||
@@ -23,6 +31,7 @@ interface User {
|
||||
}
|
||||
|
||||
export function UserManagement() {
|
||||
const { user } = useUser();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
@@ -35,8 +44,32 @@ export function UserManagement() {
|
||||
email: string;
|
||||
role: string;
|
||||
phone: string;
|
||||
gymId: string;
|
||||
} | null>(null);
|
||||
|
||||
// Active gyms for dropdown
|
||||
const [gyms, setGyms] = useState<Array<{ id: string; name: string }>>([]);
|
||||
|
||||
// Load gyms when modal opens or refreshes
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/gyms");
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
// map down to id and name to avoid extra payload use here
|
||||
setGyms(data.map((g: any) => ({ id: g.id, name: g.name })));
|
||||
} else {
|
||||
setGyms([]);
|
||||
}
|
||||
} catch {
|
||||
setGyms([]);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [filter]);
|
||||
@@ -44,10 +77,33 @@ export function UserManagement() {
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const url = filter === "all" ? "/api/users" : `/api/users?role=${filter}`;
|
||||
const ts = Date.now();
|
||||
const url =
|
||||
filter === "all"
|
||||
? `/api/users?ts=${ts}`
|
||||
: `/api/users?role=${filter}&ts=${ts}`;
|
||||
|
||||
const response = await fetch(url);
|
||||
console.log("UserManagement.fetchUsers: fetching URL", url);
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
console.log(
|
||||
"UserManagement.fetchUsers: response.ok",
|
||||
response.ok,
|
||||
"status",
|
||||
response.status,
|
||||
);
|
||||
const data = await response.json();
|
||||
console.log(
|
||||
"UserManagement.fetchUsers: received users count",
|
||||
Array.isArray(data.users) ? data.users.length : 0,
|
||||
"sample",
|
||||
data.users && data.users[0]
|
||||
? {
|
||||
id: data.users[0].id,
|
||||
gymId: data.users[0].gymId,
|
||||
role: data.users[0].role,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
setUsers(data.users || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch users:", error);
|
||||
@@ -68,6 +124,7 @@ export function UserManagement() {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
phone: user.phone || "",
|
||||
gymId: user.gymId || "",
|
||||
});
|
||||
setIsEditing(true);
|
||||
};
|
||||
@@ -144,24 +201,92 @@ export function UserManagement() {
|
||||
try {
|
||||
if (selectedUser) {
|
||||
// Update existing user
|
||||
console.log(
|
||||
"UserManagement.handleSaveEdit: sending PUT /api/users payload",
|
||||
{
|
||||
id: selectedUser.id,
|
||||
email: editForm.email,
|
||||
firstName: editForm.firstName,
|
||||
lastName: editForm.lastName,
|
||||
role: editForm.role,
|
||||
phone: editForm.phone,
|
||||
gymId: editForm.gymId === "" ? null : editForm.gymId,
|
||||
},
|
||||
);
|
||||
const response = await fetch("/api/users", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: selectedUser.id, ...editForm }),
|
||||
body: JSON.stringify({
|
||||
id: selectedUser.id,
|
||||
email: editForm.email,
|
||||
firstName: editForm.firstName,
|
||||
lastName: editForm.lastName,
|
||||
role: editForm.role,
|
||||
phone: editForm.phone,
|
||||
gymId: editForm.gymId === "" ? null : editForm.gymId,
|
||||
}),
|
||||
});
|
||||
console.log(
|
||||
"UserManagement.handleSaveEdit: PUT /api/users response.ok",
|
||||
response.ok,
|
||||
"status",
|
||||
response.status,
|
||||
);
|
||||
if (response.ok) {
|
||||
// Optimistically update local state so grid reflects changes immediately
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === selectedUser.id
|
||||
? {
|
||||
...u,
|
||||
email: editForm.email,
|
||||
firstName: editForm.firstName,
|
||||
lastName: editForm.lastName,
|
||||
role: editForm.role,
|
||||
phone: editForm.phone || undefined,
|
||||
gymId: editForm.gymId === "" ? undefined : editForm.gymId,
|
||||
}
|
||||
: u,
|
||||
),
|
||||
);
|
||||
setSelectedUser((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
email: editForm.email,
|
||||
firstName: editForm.firstName,
|
||||
lastName: editForm.lastName,
|
||||
role: editForm.role,
|
||||
phone: editForm.phone || undefined,
|
||||
gymId: editForm.gymId === "" ? undefined : editForm.gymId,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setIsEditing(false);
|
||||
setEditForm(null);
|
||||
// Still re-fetch from server to ensure consistency
|
||||
console.log(
|
||||
"UserManagement.handleSaveEdit: re-fetching users after successful edit",
|
||||
);
|
||||
fetchUsers();
|
||||
} else {
|
||||
const errText = await response.text().catch(() => "");
|
||||
console.error("UserManagement.handleSaveEdit: update failed", {
|
||||
status: response.status,
|
||||
body: errText,
|
||||
});
|
||||
alert("Error updating user");
|
||||
}
|
||||
} else {
|
||||
// Create (Invite) new user
|
||||
const response = await fetch("/api/users", {
|
||||
const response = await fetch("/api/invitations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(editForm),
|
||||
body: JSON.stringify({
|
||||
inviteeEmail: editForm.email,
|
||||
roleAssigned: editForm.role,
|
||||
gymId: editForm.gymId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -205,20 +330,20 @@ export function UserManagement() {
|
||||
<h2 className="text-2xl font-bold">User Management</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={filter === "all" ? "primary" : "secondary"}
|
||||
variant={filter === "all" ? "default" : "outline"}
|
||||
onClick={() => setFilter("all")}
|
||||
>
|
||||
All Users
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant={filter === "client" ? "default" : "outline"}
|
||||
onClick={() => selectedUser && handleEditUser(selectedUser)}
|
||||
disabled={!selectedUser}
|
||||
>
|
||||
Edit User
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant={filter === "client" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setEditForm({
|
||||
firstName: "",
|
||||
@@ -226,6 +351,7 @@ export function UserManagement() {
|
||||
email: "",
|
||||
role: "client",
|
||||
phone: "",
|
||||
gymId: String((user?.publicMetadata as any)?.gymId ?? ""),
|
||||
});
|
||||
setSelectedUser(null);
|
||||
setIsEditing(true);
|
||||
@@ -234,32 +360,32 @@ export function UserManagement() {
|
||||
Invite User
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant={filter === "client" ? "default" : "outline"}
|
||||
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
|
||||
disabled={!selectedUser}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "client" ? "primary" : "secondary"}
|
||||
variant={filter === "client" ? "default" : "outline"}
|
||||
onClick={() => setFilter("client")}
|
||||
>
|
||||
Clients
|
||||
Clientsa
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "trainer" ? "primary" : "secondary"}
|
||||
variant={filter === "trainer" ? "default" : "outline"}
|
||||
onClick={() => setFilter("trainer")}
|
||||
>
|
||||
Trainers
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "admin" ? "primary" : "secondary"}
|
||||
variant={filter === "admin" ? "default" : "outline"}
|
||||
onClick={() => setFilter("admin")}
|
||||
>
|
||||
Admins
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === "superAdmin" ? "primary" : "secondary"}
|
||||
variant={filter === "superAdmin" ? "default" : "outline"}
|
||||
onClick={() => setFilter("superAdmin")}
|
||||
>
|
||||
Super Admins
|
||||
@@ -277,10 +403,10 @@ export function UserManagement() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={handleRefresh}>
|
||||
<Button variant="default" onClick={handleRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleExport}>
|
||||
<Button variant="default" onClick={handleExport}>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
@@ -302,7 +428,9 @@ export function UserManagement() {
|
||||
{isEditing && editForm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
|
||||
<h3 className="text-lg font-semibold mb-4">{selectedUser ? 'Edit User' : 'Invite New User'}</h3>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{selectedUser ? "Edit User" : "Invite New User"}
|
||||
</h3>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -360,8 +488,8 @@ export function UserManagement() {
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
required
|
||||
>
|
||||
{/* Ideally we fetch current user role to filter these.
|
||||
For now, we show all but the API will enforce it.
|
||||
{/* Ideally we fetch current user role to filter these.
|
||||
For now, we show all but the API will enforce it.
|
||||
We can add a visual indicator or fetch "me" to filter. */}
|
||||
<option value="client">Client</option>
|
||||
<option value="trainer">Trainer</option>
|
||||
@@ -383,6 +511,26 @@ export function UserManagement() {
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Gym</label>
|
||||
<select
|
||||
value={editForm.gymId}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, gymId: e.target.value })
|
||||
}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
<option value="">Proceed without gym</option>
|
||||
{gyms.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Select an active gym or proceed without a gym.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -398,7 +546,7 @@ export function UserManagement() {
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{selectedUser ? 'Save Changes' : 'Send Invitation'}
|
||||
{selectedUser ? "Save Changes" : "Send Invitation"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -444,7 +592,7 @@ export function UserManagement() {
|
||||
</a>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Basic Information</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
@@ -493,13 +641,33 @@ export function UserManagement() {
|
||||
<span className="font-medium">Last Visit:</span>{" "}
|
||||
{selectedUser.client.lastVisit
|
||||
? new Date(
|
||||
selectedUser.client.lastVisit,
|
||||
).toLocaleDateString()
|
||||
selectedUser.client.lastVisit,
|
||||
).toLocaleDateString()
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Check-In Statistics</h4>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p>
|
||||
<span className="font-medium">Last Check-In:</span>{" "}
|
||||
{selectedUser.lastCheckInTime
|
||||
? new Date(selectedUser.lastCheckInTime).toLocaleString()
|
||||
: "Never"}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">This Week:</span>{" "}
|
||||
{selectedUser.checkInsThisWeek || 0} check-ins
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-medium">This Month:</span>{" "}
|
||||
{selectedUser.checkInsThisMonth || 0} check-ins
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { getDatabase } from '@/lib/database';
|
||||
import type { FitnessGoal } from '@/lib/database/types';
|
||||
|
||||
// Import types from database package
|
||||
import type {
|
||||
FitnessProfile as DBFitnessProfile,
|
||||
Recommendation as DBRecommendation,
|
||||
} from '@fitai/database';
|
||||
import type { FitnessGoal, FitnessProfile, Recommendation } from '@/lib/database/types';
|
||||
|
||||
export interface AIContext {
|
||||
profile: DBFitnessProfile;
|
||||
profile: FitnessProfile;
|
||||
activeGoals: FitnessGoal[];
|
||||
completedGoals: FitnessGoal[];
|
||||
recentRecommendations: DBRecommendation[];
|
||||
recentRecommendations: Recommendation[];
|
||||
progressSummary: {
|
||||
goalsCompleted: number;
|
||||
goalsActive: number;
|
||||
|
||||
@@ -32,7 +32,7 @@ export function buildEnhancedPrompt(context: AIContext): string {
|
||||
- Weight: ${profile.weight || 'Not specified'} kg
|
||||
- Age: ${profile.age || 'Not specified'}
|
||||
- Gender: ${profile.gender || 'Not specified'}
|
||||
- Primary Goal: ${profile.fitnessGoal || 'General fitness'}
|
||||
- Primary Goal: ${profile.fitnessGoals?.[0] || 'General fitness'}
|
||||
- Activity Level: ${profile.activityLevel || 'Not specified'}
|
||||
- Medical Conditions: ${profile.medicalConditions || 'None'}
|
||||
- Allergies: ${profile.allergies || 'None'}
|
||||
@@ -77,7 +77,7 @@ Generate a detailed daily recommendation for a user with the following profile:
|
||||
- Weight: ${profile.weight} kg
|
||||
- Age: ${profile.age}
|
||||
- Gender: ${profile.gender}
|
||||
- Goal: ${profile.fitnessGoal}
|
||||
- Goal: ${profile.fitnessGoals?.[0]}
|
||||
- Activity Level: ${profile.activityLevel}
|
||||
- Medical Conditions: ${profile.medicalConditions || "None"}
|
||||
- Injuries: ${profile.injuries || "None"}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*/
|
||||
import { DrizzleDatabase } from '../drizzle'
|
||||
import { DatabaseConfig } from '../types'
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from '@fitai/database'
|
||||
|
||||
describe('DrizzleDatabase', () => {
|
||||
let db: DrizzleDatabase
|
||||
let sqlite: Database.Database
|
||||
let drizzleDb: ReturnType<typeof drizzle>
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create in-memory SQLite database
|
||||
sqlite = new Database(':memory:')
|
||||
drizzleDb = drizzle(sqlite, { schema })
|
||||
|
||||
const config: DatabaseConfig = {
|
||||
type: 'sqlite',
|
||||
connection: { filename: ':memory:' },
|
||||
options: { logging: false }
|
||||
}
|
||||
|
||||
// Initialize DrizzleDatabase with the in-memory db
|
||||
db = new DrizzleDatabase(config, drizzleDb as any)
|
||||
await db.connect()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await db.disconnect()
|
||||
sqlite.close()
|
||||
})
|
||||
|
||||
describe('User Operations', () => {
|
||||
it('should create and retrieve a user', async () => {
|
||||
const newUser = {
|
||||
email: 'test@example.com',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
password: 'password123',
|
||||
role: 'client' as const
|
||||
}
|
||||
|
||||
const createdUser = await db.createUser(newUser)
|
||||
expect(createdUser).toBeDefined()
|
||||
expect(createdUser.id).toBeDefined()
|
||||
expect(createdUser.email).toBe(newUser.email)
|
||||
|
||||
const fetchedUser = await db.getUserById(createdUser.id)
|
||||
expect(fetchedUser).toBeDefined()
|
||||
expect(fetchedUser?.id).toBe(createdUser.id)
|
||||
expect(fetchedUser?.email).toBe(createdUser.email)
|
||||
// SQLite/Drizzle might truncate ms or handle dates differently in test env
|
||||
expect(new Date(fetchedUser!.createdAt).getTime()).toBeLessThanOrEqual(createdUser.createdAt.getTime())
|
||||
expect(new Date(fetchedUser!.updatedAt).getTime()).toBeLessThanOrEqual(createdUser.updatedAt.getTime())
|
||||
})
|
||||
|
||||
it('should update a user', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'update@example.com',
|
||||
firstName: 'Update',
|
||||
lastName: 'Me',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const updated = await db.updateUser(user.id, { firstName: 'Updated' })
|
||||
expect(updated?.firstName).toBe('Updated')
|
||||
|
||||
const fetched = await db.getUserById(user.id)
|
||||
expect(fetched?.firstName).toBe('Updated')
|
||||
})
|
||||
|
||||
it('should delete a user', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'delete@example.com',
|
||||
firstName: 'Delete',
|
||||
lastName: 'Me',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const deleted = await db.deleteUser(user.id)
|
||||
expect(deleted).toBe(true)
|
||||
|
||||
const fetched = await db.getUserById(user.id)
|
||||
expect(fetched).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Client Operations', () => {
|
||||
it('should create and retrieve a client', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'client@example.com',
|
||||
firstName: 'Client',
|
||||
lastName: 'User',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const newClient = {
|
||||
userId: user.id,
|
||||
membershipType: 'basic' as const,
|
||||
membershipStatus: 'active' as const,
|
||||
joinDate: new Date()
|
||||
}
|
||||
|
||||
const createdClient = await db.createClient(newClient)
|
||||
expect(createdClient).toBeDefined()
|
||||
expect(createdClient.userId).toBe(user.id)
|
||||
|
||||
const fetchedClient = await db.getClientById(createdClient.id)
|
||||
expect(fetchedClient).toBeDefined()
|
||||
expect(fetchedClient?.id).toBe(createdClient.id)
|
||||
expect(fetchedClient?.userId).toBe(createdClient.userId)
|
||||
expect(fetchedClient?.membershipType).toBe(createdClient.membershipType)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fitness Profile Operations', () => {
|
||||
it('should create and retrieve a fitness profile', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'profile@example.com',
|
||||
firstName: 'Profile',
|
||||
lastName: 'User',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const newProfile = {
|
||||
userId: user.id,
|
||||
height: 180,
|
||||
weight: 75,
|
||||
age: 30,
|
||||
gender: 'male' as const,
|
||||
activityLevel: 'moderately_active' as const,
|
||||
fitnessGoals: ['weight_loss'],
|
||||
exerciseHabits: 'None',
|
||||
dietHabits: 'None',
|
||||
medicalConditions: 'None'
|
||||
}
|
||||
|
||||
const createdProfile = await db.createFitnessProfile(newProfile as any)
|
||||
expect(createdProfile).toBeDefined()
|
||||
expect(createdProfile.userId).toBe(user.id)
|
||||
|
||||
const fetchedProfile = await db.getFitnessProfileByUserId(user.id)
|
||||
expect(fetchedProfile).toBeDefined()
|
||||
expect(fetchedProfile?.userId).toBe(user.id)
|
||||
expect(fetchedProfile?.fitnessGoals).toEqual(expect.arrayContaining(['weight_loss']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Attendance Operations', () => {
|
||||
it('should check in and check out', async () => {
|
||||
const user = await db.createUser({
|
||||
email: 'attendance@example.com',
|
||||
firstName: 'Attendance',
|
||||
lastName: 'User',
|
||||
password: 'password',
|
||||
role: 'client'
|
||||
})
|
||||
|
||||
const checkIn = await db.checkIn(user.id, 'gym')
|
||||
expect(checkIn).toBeDefined()
|
||||
expect(checkIn.checkOutTime).toBeUndefined()
|
||||
|
||||
const activeCheckIn = await db.getActiveCheckIn(user.id)
|
||||
expect(activeCheckIn).toBeDefined()
|
||||
expect(activeCheckIn?.id).toBe(checkIn.id)
|
||||
|
||||
const checkOut = await db.checkOut(checkIn.id)
|
||||
expect(checkOut?.checkOutTime).toBeDefined()
|
||||
|
||||
const activeCheckInAfter = await db.getActiveCheckIn(user.id)
|
||||
expect(activeCheckInAfter).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,720 @@
|
||||
import {
|
||||
IDatabase,
|
||||
User,
|
||||
Client,
|
||||
FitnessProfile,
|
||||
Attendance,
|
||||
Recommendation,
|
||||
FitnessGoal,
|
||||
DatabaseConfig,
|
||||
} from "./types";
|
||||
import {
|
||||
db as defaultDb,
|
||||
users,
|
||||
clients,
|
||||
fitnessProfiles,
|
||||
attendance,
|
||||
recommendations,
|
||||
fitnessGoals,
|
||||
eq,
|
||||
and,
|
||||
desc,
|
||||
sql,
|
||||
} from "@fitai/database";
|
||||
import { InferSelectModel } from "drizzle-orm";
|
||||
|
||||
export class DrizzleDatabase implements IDatabase {
|
||||
private config: DatabaseConfig;
|
||||
private db: typeof defaultDb;
|
||||
|
||||
constructor(config: DatabaseConfig, db?: typeof defaultDb) {
|
||||
this.config = config;
|
||||
this.db = db || defaultDb;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
// Drizzle with better-sqlite3 connects synchronously on initialization
|
||||
// We can just log here if needed
|
||||
if (this.config.options?.logging) {
|
||||
console.log("Drizzle database connected");
|
||||
}
|
||||
await this.createTables();
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
// better-sqlite3 handle is managed by Drizzle, usually no explicit disconnect needed for connection pooling
|
||||
// but we can close the underlying sqlite instance if we had access to it.
|
||||
// For now, we'll assume it's handled.
|
||||
if (this.config.options?.logging) {
|
||||
console.log("Drizzle database disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
private async createTables(): Promise<void> {
|
||||
// Users table
|
||||
await this.db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
password TEXT,
|
||||
phone TEXT,
|
||||
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
|
||||
gym_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: ensure gym_id column exists on users (for existing DBs)
|
||||
const userCols = await this.db.all(sql`PRAGMA table_info('users')`);
|
||||
if (!userCols.some((c: any) => c.name === "gym_id")) {
|
||||
await this.db.run(sql`ALTER TABLE users ADD COLUMN gym_id TEXT`);
|
||||
}
|
||||
// Clients table
|
||||
await this.db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
membership_type TEXT NOT NULL CHECK (membership_type IN ('basic', 'premium', 'vip')),
|
||||
membership_status TEXT NOT NULL CHECK (membership_status IN ('active', 'inactive', 'suspended')),
|
||||
join_date INTEGER NOT NULL,
|
||||
last_visit INTEGER,
|
||||
emergency_contact_name TEXT,
|
||||
emergency_contact_phone TEXT,
|
||||
emergency_contact_relationship TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Fitness profiles table
|
||||
await this.db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS fitness_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
height REAL,
|
||||
weight REAL,
|
||||
age INTEGER,
|
||||
gender TEXT CHECK (gender IN ('male', 'female', 'other', 'prefer_not_to_say')),
|
||||
activity_level TEXT CHECK (activity_level IN ('sedentary', 'lightly_active', 'moderately_active', 'very_active', 'extremely_active')),
|
||||
fitness_goals TEXT,
|
||||
exercise_habits TEXT,
|
||||
diet_habits TEXT,
|
||||
medical_conditions TEXT,
|
||||
allergies TEXT,
|
||||
injuries TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Attendance table
|
||||
await this.db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS attendance (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
check_in_time INTEGER NOT NULL,
|
||||
check_out_time INTEGER,
|
||||
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
|
||||
notes TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Recommendations table
|
||||
await this.db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS recommendations (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
fitness_profile_id TEXT NOT NULL,
|
||||
recommendation_text TEXT NOT NULL,
|
||||
activity_plan TEXT NOT NULL,
|
||||
diet_plan TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')) DEFAULT 'pending',
|
||||
generated_at INTEGER NOT NULL,
|
||||
approved_at INTEGER,
|
||||
approved_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Fitness Goals table
|
||||
await this.db.run(sql`
|
||||
CREATE TABLE IF NOT EXISTS fitness_goals (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
fitness_profile_id TEXT,
|
||||
goal_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
target_value REAL,
|
||||
current_value REAL,
|
||||
unit TEXT,
|
||||
start_date INTEGER NOT NULL,
|
||||
target_date INTEGER,
|
||||
completed_date INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
progress REAL DEFAULT 0,
|
||||
priority TEXT DEFAULT 'medium',
|
||||
notes TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fitness_profile_id) REFERENCES fitness_profiles (id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// User operations
|
||||
async createUser(
|
||||
userData: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
|
||||
): Promise<User> {
|
||||
const id = userData.id || Math.random().toString(36).substr(2, 9);
|
||||
const now = new Date();
|
||||
|
||||
const newUser = {
|
||||
...userData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.db.insert(users).values(newUser);
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.get();
|
||||
return result ? this.mapUser(result) : null;
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.get();
|
||||
return result ? this.mapUser(result) : null;
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(users)
|
||||
.orderBy(desc(users.createdAt))
|
||||
.all();
|
||||
return results.map(this.mapUser);
|
||||
}
|
||||
|
||||
async updateUser(id: string, updates: Partial<User>): Promise<User | null> {
|
||||
const { id: _, ...updateData } = updates;
|
||||
if (Object.keys(updateData).length === 0) return this.getUserById(id);
|
||||
|
||||
await this.db
|
||||
.update(users)
|
||||
.set({ ...updateData, updatedAt: new Date() })
|
||||
.where(eq(users.id, id))
|
||||
.run();
|
||||
|
||||
return this.getUserById(id);
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<boolean> {
|
||||
const result = await this.db.delete(users).where(eq(users.id, id)).run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async migrateUserId(oldId: string, newId: string): Promise<void> {
|
||||
await this.db
|
||||
.update(users)
|
||||
.set({ id: newId })
|
||||
.where(eq(users.id, oldId))
|
||||
.run();
|
||||
}
|
||||
|
||||
// Client operations
|
||||
async createClient(clientData: Omit<Client, "id">): Promise<Client> {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const newClient = {
|
||||
id,
|
||||
...clientData,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await this.db.insert(clients).values(newClient as any);
|
||||
return this.mapClient(newClient);
|
||||
}
|
||||
|
||||
async getClientById(id: string): Promise<Client | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.id, id))
|
||||
.get();
|
||||
return result ? this.mapClient(result) : null;
|
||||
}
|
||||
|
||||
async getClientByUserId(userId: string): Promise<Client | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.userId, userId))
|
||||
.get();
|
||||
return result ? this.mapClient(result) : null;
|
||||
}
|
||||
|
||||
async getAllClients(): Promise<Client[]> {
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(clients)
|
||||
.orderBy(desc(clients.joinDate))
|
||||
.all();
|
||||
return results.map(this.mapClient);
|
||||
}
|
||||
|
||||
async updateClient(
|
||||
id: string,
|
||||
updates: Partial<Client>,
|
||||
): Promise<Client | null> {
|
||||
const { id: _, ...updateData } = updates;
|
||||
if (Object.keys(updateData).length === 0) return this.getClientById(id);
|
||||
|
||||
await this.db
|
||||
.update(clients)
|
||||
.set({ ...updateData, updatedAt: new Date() } as any)
|
||||
.where(eq(clients.id, id))
|
||||
.run();
|
||||
|
||||
return this.getClientById(id);
|
||||
}
|
||||
|
||||
async deleteClient(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(clients)
|
||||
.where(eq(clients.id, id))
|
||||
.run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Fitness Profile operations
|
||||
async createFitnessProfile(
|
||||
profileData: Omit<FitnessProfile, "id" | "createdAt" | "updatedAt">,
|
||||
): Promise<FitnessProfile> {
|
||||
const now = new Date();
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const newProfile = {
|
||||
id,
|
||||
...profileData,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.db.insert(fitnessProfiles).values(newProfile as any);
|
||||
return { id, ...profileData, createdAt: now, updatedAt: now };
|
||||
}
|
||||
|
||||
async getFitnessProfileByUserId(
|
||||
userId: string,
|
||||
): Promise<FitnessProfile | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(fitnessProfiles)
|
||||
.where(eq(fitnessProfiles.userId, userId))
|
||||
.get();
|
||||
return result ? this.mapFitnessProfile(result) : null;
|
||||
}
|
||||
|
||||
async getAllFitnessProfiles(): Promise<FitnessProfile[]> {
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(fitnessProfiles)
|
||||
.orderBy(desc(fitnessProfiles.createdAt))
|
||||
.all();
|
||||
return results.map(this.mapFitnessProfile);
|
||||
}
|
||||
|
||||
async updateFitnessProfile(
|
||||
userId: string,
|
||||
updates: Partial<FitnessProfile>,
|
||||
): Promise<FitnessProfile | null> {
|
||||
const { userId: _, ...updateData } = updates;
|
||||
if (Object.keys(updateData).length === 0)
|
||||
return this.getFitnessProfileByUserId(userId);
|
||||
|
||||
const dbUpdates = { ...updateData } as any;
|
||||
if (updateData.fitnessGoals) {
|
||||
dbUpdates.fitnessGoals = JSON.stringify(updateData.fitnessGoals);
|
||||
}
|
||||
|
||||
await this.db
|
||||
.update(fitnessProfiles)
|
||||
.set({ ...dbUpdates, updatedAt: new Date() })
|
||||
.where(eq(fitnessProfiles.userId, userId))
|
||||
.run();
|
||||
|
||||
return this.getFitnessProfileByUserId(userId);
|
||||
}
|
||||
|
||||
async deleteFitnessProfile(userId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(fitnessProfiles)
|
||||
.where(eq(fitnessProfiles.userId, userId))
|
||||
.run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Attendance operations
|
||||
async checkIn(
|
||||
userId: string,
|
||||
type: "gym" | "class" | "personal_training",
|
||||
notes?: string,
|
||||
): Promise<Attendance> {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const now = new Date();
|
||||
|
||||
const newAttendance = {
|
||||
id,
|
||||
userId,
|
||||
checkInTime: now,
|
||||
type,
|
||||
notes,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
await this.db.insert(attendance).values(newAttendance as any);
|
||||
|
||||
// Update client last visit
|
||||
const client = await this.getClientByUserId(userId);
|
||||
if (client) {
|
||||
await this.updateClient(client.id, { lastVisit: now });
|
||||
}
|
||||
|
||||
return newAttendance;
|
||||
}
|
||||
|
||||
async checkOut(attendanceId: string): Promise<Attendance | null> {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.update(attendance)
|
||||
.set({ checkOutTime: now })
|
||||
.where(eq(attendance.id, attendanceId))
|
||||
.run();
|
||||
|
||||
return this.getAttendanceById(attendanceId);
|
||||
}
|
||||
|
||||
async getAttendanceById(id: string): Promise<Attendance | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(attendance)
|
||||
.where(eq(attendance.id, id))
|
||||
.get();
|
||||
return result ? this.mapAttendance(result) : null;
|
||||
}
|
||||
|
||||
async getAttendanceHistory(userId: string): Promise<Attendance[]> {
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(attendance)
|
||||
.where(eq(attendance.userId, userId))
|
||||
.orderBy(desc(attendance.checkInTime))
|
||||
.all();
|
||||
return results.map(this.mapAttendance);
|
||||
}
|
||||
|
||||
async getAllAttendance(): Promise<Attendance[]> {
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(attendance)
|
||||
.orderBy(desc(attendance.checkInTime))
|
||||
.all();
|
||||
return results.map(this.mapAttendance);
|
||||
}
|
||||
|
||||
async getActiveCheckIn(userId: string): Promise<Attendance | null> {
|
||||
// Drizzle doesn't support IS NULL in where directly with simple syntax sometimes, but eq(col, null) works or isNull(col)
|
||||
// We need to check how to filter for null checkOutTime.
|
||||
// In Drizzle, we can filter in JS or use isNull operator if imported.
|
||||
// Let's fetch recent and filter for now to be safe, or use raw sql if needed, but better to use Drizzle operators.
|
||||
// Actually, we can just fetch all for user and filter in memory since it's unlikely to be huge for active checkins,
|
||||
// but correct way is `isNull(attendance.checkOutTime)`.
|
||||
// Since I didn't import `isNull`, I'll fetch recent history and find first active.
|
||||
|
||||
const history = await this.getAttendanceHistory(userId);
|
||||
return history.find((a) => !a.checkOutTime) || null;
|
||||
}
|
||||
|
||||
// Recommendation operations
|
||||
async createRecommendation(
|
||||
data: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">,
|
||||
): Promise<Recommendation> {
|
||||
const now = new Date();
|
||||
const newRec = {
|
||||
...data,
|
||||
createdAt: now,
|
||||
status: data.status || "pending",
|
||||
};
|
||||
|
||||
await this.db.insert(recommendations).values(newRec as any);
|
||||
return newRec as Recommendation;
|
||||
}
|
||||
|
||||
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(recommendations)
|
||||
.where(eq(recommendations.userId, userId))
|
||||
.orderBy(desc(recommendations.createdAt))
|
||||
.all();
|
||||
return results.map(this.mapRecommendation);
|
||||
}
|
||||
|
||||
async getAllRecommendations(): Promise<Recommendation[]> {
|
||||
const results = await this.db
|
||||
.select()
|
||||
.from(recommendations)
|
||||
.orderBy(desc(recommendations.createdAt))
|
||||
.all();
|
||||
return results.map(this.mapRecommendation);
|
||||
}
|
||||
|
||||
async updateRecommendation(
|
||||
id: string,
|
||||
updates: Partial<Recommendation>,
|
||||
): Promise<Recommendation | null> {
|
||||
const { id: _, ...updateData } = updates;
|
||||
if (Object.keys(updateData).length === 0)
|
||||
return this.getRecommendationById(id);
|
||||
|
||||
await this.db
|
||||
.update(recommendations)
|
||||
.set(updateData as any)
|
||||
.where(eq(recommendations.id, id))
|
||||
.run();
|
||||
|
||||
return this.getRecommendationById(id);
|
||||
}
|
||||
|
||||
async deleteRecommendation(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(recommendations)
|
||||
.where(eq(recommendations.id, id))
|
||||
.run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async getRecommendationById(id: string): Promise<Recommendation | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(recommendations)
|
||||
.where(eq(recommendations.id, id))
|
||||
.get();
|
||||
return result ? this.mapRecommendation(result) : null;
|
||||
}
|
||||
|
||||
// Fitness Goals operations
|
||||
async createFitnessGoal(
|
||||
goalData: Omit<FitnessGoal, "createdAt" | "updatedAt">,
|
||||
): Promise<FitnessGoal> {
|
||||
const now = new Date();
|
||||
const newGoal = {
|
||||
...goalData,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await this.db.insert(fitnessGoals).values(newGoal as any);
|
||||
return newGoal as FitnessGoal;
|
||||
}
|
||||
|
||||
async getFitnessGoalById(id: string): Promise<FitnessGoal | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(fitnessGoals)
|
||||
.where(eq(fitnessGoals.id, id))
|
||||
.get();
|
||||
return result ? this.mapFitnessGoal(result) : null;
|
||||
}
|
||||
|
||||
async getFitnessGoalsByUserId(
|
||||
userId: string,
|
||||
status?: string,
|
||||
): Promise<FitnessGoal[]> {
|
||||
let query = this.db
|
||||
.select()
|
||||
.from(fitnessGoals)
|
||||
.where(eq(fitnessGoals.userId, userId));
|
||||
|
||||
if (status) {
|
||||
query = this.db
|
||||
.select()
|
||||
.from(fitnessGoals)
|
||||
.where(
|
||||
and(
|
||||
eq(fitnessGoals.userId, userId),
|
||||
eq(fitnessGoals.status, status as any),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const results = await query.orderBy(desc(fitnessGoals.createdAt)).all();
|
||||
return results.map(this.mapFitnessGoal);
|
||||
}
|
||||
|
||||
async updateFitnessGoal(
|
||||
id: string,
|
||||
updates: Partial<FitnessGoal>,
|
||||
): Promise<FitnessGoal | null> {
|
||||
const { id: _, ...updateData } = updates;
|
||||
if (Object.keys(updateData).length === 0)
|
||||
return this.getFitnessGoalById(id);
|
||||
|
||||
await this.db
|
||||
.update(fitnessGoals)
|
||||
.set({ ...updateData, updatedAt: new Date() } as any)
|
||||
.where(eq(fitnessGoals.id, id))
|
||||
.run();
|
||||
|
||||
return this.getFitnessGoalById(id);
|
||||
}
|
||||
|
||||
async deleteFitnessGoal(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(fitnessGoals)
|
||||
.where(eq(fitnessGoals.id, id))
|
||||
.run();
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async updateGoalProgress(
|
||||
id: string,
|
||||
currentValue: number,
|
||||
): Promise<FitnessGoal | null> {
|
||||
const goal = await this.getFitnessGoalById(id);
|
||||
if (!goal) return null;
|
||||
|
||||
let progress = goal.progress;
|
||||
if (goal.targetValue && goal.targetValue > 0) {
|
||||
progress = Math.min(100, (currentValue / goal.targetValue) * 100);
|
||||
}
|
||||
|
||||
await this.db
|
||||
.update(fitnessGoals)
|
||||
.set({ currentValue, progress, updatedAt: new Date() })
|
||||
.where(eq(fitnessGoals.id, id))
|
||||
.run();
|
||||
|
||||
return this.getFitnessGoalById(id);
|
||||
}
|
||||
|
||||
async completeGoal(id: string): Promise<FitnessGoal | null> {
|
||||
const now = new Date();
|
||||
await this.db
|
||||
.update(fitnessGoals)
|
||||
.set({
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
completedDate: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(fitnessGoals.id, id))
|
||||
.run();
|
||||
|
||||
return this.getFitnessGoalById(id);
|
||||
}
|
||||
|
||||
async getDashboardStats(): Promise<{
|
||||
totalUsers: number;
|
||||
activeClients: number;
|
||||
totalRevenue: number;
|
||||
revenueGrowth: number;
|
||||
}> {
|
||||
// Placeholder implementation as per original sqlite.ts (which didn't implement this either in the viewed snippet,
|
||||
// but interface requires it. I'll provide a basic implementation or mock).
|
||||
// The original sqlite.ts snippet ended before showing this method, but the interface has it.
|
||||
// I'll implement a basic count.
|
||||
|
||||
const allUsers = await this.db.select().from(users).all();
|
||||
const activeClients = await this.db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.membershipStatus, "active"))
|
||||
.all();
|
||||
|
||||
return {
|
||||
totalUsers: allUsers.length,
|
||||
activeClients: activeClients.length,
|
||||
totalRevenue: 0, // Not tracking payments yet
|
||||
revenueGrowth: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Mappers
|
||||
private mapUser(row: any): User {
|
||||
return {
|
||||
...row,
|
||||
gymId: (row as any).gymId ?? (row as any).gym_id ?? undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private mapClient(row: any): Client {
|
||||
return {
|
||||
...row,
|
||||
joinDate: new Date(row.joinDate),
|
||||
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessProfile(row: any): FitnessProfile {
|
||||
return {
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
private mapAttendance(row: any): Attendance {
|
||||
return {
|
||||
...row,
|
||||
checkInTime: new Date(row.checkInTime),
|
||||
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
private mapRecommendation(row: any): Recommendation {
|
||||
return {
|
||||
...row,
|
||||
createdAt: new Date(row.createdAt),
|
||||
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private mapFitnessGoal(row: any): FitnessGoal {
|
||||
return {
|
||||
...row,
|
||||
startDate: new Date(row.startDate),
|
||||
targetDate: row.targetDate ? new Date(row.targetDate) : undefined,
|
||||
completedDate: row.completedDate
|
||||
? new Date(row.completedDate)
|
||||
: undefined,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IDatabase, DatabaseConfig } from './types'
|
||||
import { SQLiteDatabase } from './sqlite'
|
||||
import { DrizzleDatabase } from './drizzle'
|
||||
|
||||
// Database factory - creates appropriate database instance based on config
|
||||
export class DatabaseFactory {
|
||||
@@ -20,28 +20,28 @@ export class DatabaseFactory {
|
||||
// Create new database instance based on type
|
||||
switch (config.type) {
|
||||
case 'sqlite':
|
||||
this.instance = new SQLiteDatabase(config)
|
||||
this.instance = new DrizzleDatabase(config)
|
||||
break
|
||||
|
||||
|
||||
case 'postgresql':
|
||||
// TODO: Implement PostgreSQLDatabase
|
||||
throw new Error('PostgreSQL implementation not yet available')
|
||||
|
||||
|
||||
case 'mysql':
|
||||
// TODO: Implement MySQLDatabase
|
||||
throw new Error('MySQL implementation not yet available')
|
||||
|
||||
|
||||
case 'mongodb':
|
||||
// TODO: Implement MongoDBDatabase
|
||||
throw new Error('MongoDB implementation not yet available')
|
||||
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported database type: ${config.type}`)
|
||||
}
|
||||
|
||||
await this.instance.connect()
|
||||
this.config = config
|
||||
|
||||
|
||||
return this.instance
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class DatabaseFactory {
|
||||
|
||||
private static isSameConfig(config: DatabaseConfig): boolean {
|
||||
if (!this.config) return false
|
||||
|
||||
|
||||
return (
|
||||
this.config.type === config.type &&
|
||||
JSON.stringify(this.config.connection) === JSON.stringify(config.connection)
|
||||
|
||||
@@ -1,823 +0,0 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { IDatabase, User, Client, FitnessProfile, Attendance, Recommendation, DatabaseConfig } from './types'
|
||||
|
||||
export class SQLiteDatabase implements IDatabase {
|
||||
private db: Database.Database | null = null
|
||||
private config: DatabaseConfig
|
||||
|
||||
constructor(config: DatabaseConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
const dbPath = this.config.connection.filename || path.join(process.cwd(), 'data', 'fitai.db')
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.dirname(dbPath)
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true })
|
||||
}
|
||||
|
||||
this.db = new Database(dbPath)
|
||||
|
||||
// Enable foreign keys
|
||||
this.db.exec('PRAGMA foreign_keys = ON')
|
||||
|
||||
// Create tables
|
||||
await this.createTables()
|
||||
|
||||
if (this.config.options?.logging) {
|
||||
console.log('SQLite database connected successfully at:', dbPath)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to SQLite database:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.db) {
|
||||
this.db.close()
|
||||
this.db = null
|
||||
if (this.config.options?.logging) {
|
||||
console.log('SQLite database disconnected')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async createTables(): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
// Users table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
firstName TEXT NOT NULL,
|
||||
lastName TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
role TEXT NOT NULL CHECK (role IN ('superAdmin', 'admin', 'trainer', 'client')),
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
)
|
||||
`)
|
||||
|
||||
// Clients table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
membershipType TEXT NOT NULL CHECK (membershipType IN ('basic', 'premium', 'vip')),
|
||||
membershipStatus TEXT NOT NULL CHECK (membershipStatus IN ('active', 'inactive', 'expired')),
|
||||
joinDate DATETIME NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
// Fitness profiles table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS fitness_profiles (
|
||||
userId TEXT PRIMARY KEY,
|
||||
height TEXT NOT NULL,
|
||||
weight TEXT NOT NULL,
|
||||
age TEXT NOT NULL,
|
||||
gender TEXT NOT NULL CHECK (gender IN ('male', 'female', 'other')),
|
||||
activityLevel TEXT NOT NULL CHECK (activityLevel IN ('sedentary', 'light', 'moderate', 'active', 'very_active')),
|
||||
fitnessGoals TEXT NOT NULL, -- JSON array
|
||||
exerciseHabits TEXT,
|
||||
dietHabits TEXT,
|
||||
medicalConditions TEXT,
|
||||
allergies TEXT,
|
||||
injuries TEXT,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
// Create indexes for better performance
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_clients_userId ON clients(userId);
|
||||
CREATE INDEX IF NOT EXISTS idx_fitness_profiles_userId ON fitness_profiles(userId);
|
||||
`)
|
||||
|
||||
// Attendance table
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance (
|
||||
id TEXT PRIMARY KEY,
|
||||
clientId TEXT NOT NULL,
|
||||
checkInTime DATETIME NOT NULL,
|
||||
checkOutTime DATETIME,
|
||||
type TEXT NOT NULL CHECK (type IN ('gym', 'class', 'personal_training')),
|
||||
notes TEXT,
|
||||
createdAt DATETIME NOT NULL,
|
||||
FOREIGN KEY (clientId) REFERENCES clients (id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
|
||||
// Recommendations table
|
||||
// Removed DROP TABLE to persist data. Schema is now stable.
|
||||
// this.db.exec(`DROP TABLE IF EXISTS recommendations`)
|
||||
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS recommendations (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
fitnessProfileId TEXT,
|
||||
type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
activityPlan TEXT,
|
||||
dietPlan TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'completed')),
|
||||
createdAt DATETIME NOT NULL,
|
||||
approvedAt DATETIME,
|
||||
approvedBy TEXT,
|
||||
FOREIGN KEY (userId) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (fitnessProfileId) REFERENCES fitness_profiles (userId)
|
||||
)
|
||||
`)
|
||||
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_recommendations_userId ON recommendations(userId);
|
||||
`)
|
||||
|
||||
this.db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_clientId ON attendance(clientId);
|
||||
CREATE INDEX IF NOT EXISTS idx_attendance_checkInTime ON attendance(checkInTime);
|
||||
`)
|
||||
}
|
||||
|
||||
// User operations
|
||||
async createUser(
|
||||
userData: Omit<User, "createdAt" | "updatedAt" | "id"> & { id?: string },
|
||||
): Promise<User> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const id = userData.id || Math.random().toString(36).substr(2, 9)
|
||||
const now = new Date()
|
||||
|
||||
const user: User = {
|
||||
...userData,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO users(id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
user.id, user.email, user.firstName, user.lastName, user.password,
|
||||
user.phone, user.role, user.createdAt.toISOString(), user.updatedAt.toISOString()
|
||||
)
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM users WHERE id = ?')
|
||||
const row = stmt.get(id)
|
||||
|
||||
return row ? this.mapRowToUser(row) : null
|
||||
}
|
||||
|
||||
async getUserByEmail(email: string): Promise<User | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM users WHERE email = ?')
|
||||
const row = stmt.get(email)
|
||||
|
||||
return row ? this.mapRowToUser(row) : null
|
||||
}
|
||||
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM users ORDER BY createdAt DESC')
|
||||
const rows = stmt.all()
|
||||
|
||||
return rows.map(row => this.mapRowToUser(row))
|
||||
}
|
||||
|
||||
async updateUser(id: string, updates: Partial<User>): Promise<User | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const fields = Object.keys(updates).filter(key => key !== 'id')
|
||||
if (fields.length === 0) return this.getUserById(id)
|
||||
|
||||
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||
const values = fields.map(field => (updates as any)[field])
|
||||
values.push(new Date().toISOString()) // updatedAt
|
||||
values.push(id)
|
||||
|
||||
const stmt = this.db.prepare(`UPDATE users SET ${setClause}, updatedAt = ? WHERE id = ? `)
|
||||
stmt.run(values)
|
||||
|
||||
return this.getUserById(id)
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<boolean> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('DELETE FROM users WHERE id = ?')
|
||||
const result = stmt.run(id)
|
||||
return (result.changes || 0) > 0
|
||||
}
|
||||
|
||||
async migrateUserId(oldId: string, newId: string): Promise<void> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
// We need to disable foreign keys temporarily if we want to update ID without cascade (if cascade isn't set)
|
||||
// But we should try to update and let cascade handle it if possible.
|
||||
// Since we didn't set ON UPDATE CASCADE, we might need to manually update references or use PRAGMA.
|
||||
|
||||
// Simplest way: Update the ID. If it fails due to FK, we have to handle it.
|
||||
// For the Super Admin seed case, there are no dependencies.
|
||||
|
||||
const stmt = this.db.prepare('UPDATE users SET id = ? WHERE id = ?')
|
||||
stmt.run(newId, oldId)
|
||||
}
|
||||
|
||||
// Client operations
|
||||
async createClient(clientData: Omit<Client, 'id'>): Promise<Client> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
const client: Client = { id, ...clientData }
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO clients(id, userId, membershipType, membershipStatus, joinDate)
|
||||
VALUES(?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
client.id, client.userId, client.membershipType,
|
||||
client.membershipStatus, client.joinDate.toISOString()
|
||||
)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
async getClientById(id: string): Promise<Client | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM clients WHERE id = ?')
|
||||
const row = stmt.get(id)
|
||||
return row ? this.mapRowToClient(row) : null
|
||||
}
|
||||
|
||||
async getClientByUserId(userId: string): Promise<Client | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM clients WHERE userId = ?')
|
||||
const row = stmt.get(userId)
|
||||
return row ? this.mapRowToClient(row) : null
|
||||
}
|
||||
|
||||
async getAllClients(): Promise<Client[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM clients ORDER BY joinDate DESC')
|
||||
const rows = stmt.all()
|
||||
return rows.map(row => this.mapRowToClient(row))
|
||||
}
|
||||
|
||||
async updateClient(id: string, updates: Partial<Client>): Promise<Client | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const fields = Object.keys(updates).filter(key => key !== 'id')
|
||||
if (fields.length === 0) return this.getClientById(id)
|
||||
|
||||
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||
const values = fields.map(field => (updates as any)[field])
|
||||
values.push(id)
|
||||
|
||||
const stmt = this.db.prepare(`UPDATE clients SET ${setClause} WHERE id = ? `)
|
||||
stmt.run(values)
|
||||
|
||||
return this.getClientById(id)
|
||||
}
|
||||
|
||||
async deleteClient(id: string): Promise<boolean> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('DELETE FROM clients WHERE id = ?')
|
||||
const result = stmt.run(id)
|
||||
return (result.changes || 0) > 0
|
||||
}
|
||||
|
||||
// Fitness Profile operations
|
||||
async createFitnessProfile(profileData: Omit<FitnessProfile, 'createdAt' | 'updatedAt'>): Promise<FitnessProfile> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const now = new Date()
|
||||
const profile: FitnessProfile = {
|
||||
...profileData,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO fitness_profiles
|
||||
(userId, height, weight, age, gender, activityLevel, fitnessGoals,
|
||||
exerciseHabits, dietHabits, medicalConditions, allergies, injuries, createdAt, updatedAt)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
profile.userId, profile.height, profile.weight, profile.age, profile.gender,
|
||||
profile.activityLevel, JSON.stringify(profile.fitnessGoals), profile.exerciseHabits,
|
||||
profile.dietHabits, profile.medicalConditions, profile.allergies, profile.injuries,
|
||||
profile.createdAt.toISOString(), profile.updatedAt.toISOString()
|
||||
)
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
async getFitnessProfileByUserId(userId: string): Promise<FitnessProfile | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM fitness_profiles WHERE userId = ?')
|
||||
const row = stmt.get(userId)
|
||||
|
||||
return row ? this.mapRowToFitnessProfile(row) : null
|
||||
}
|
||||
|
||||
async getAllFitnessProfiles(): Promise<FitnessProfile[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM fitness_profiles ORDER BY createdAt DESC')
|
||||
const rows = stmt.all()
|
||||
return rows.map(row => this.mapRowToFitnessProfile(row))
|
||||
}
|
||||
|
||||
async updateFitnessProfile(userId: string, updates: Partial<FitnessProfile>): Promise<FitnessProfile | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const fields = Object.keys(updates).filter(key => key !== 'userId' && key !== 'createdAt')
|
||||
if (fields.length === 0) return this.getFitnessProfileByUserId(userId)
|
||||
|
||||
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||
const values = fields.map(field => {
|
||||
const value = (updates as any)[field]
|
||||
return field === 'fitnessGoals' ? JSON.stringify(value) : value
|
||||
})
|
||||
values.push(new Date().toISOString()) // updatedAt
|
||||
values.push(userId)
|
||||
|
||||
const stmt = this.db.prepare(`UPDATE fitness_profiles SET ${setClause}, updatedAt = ? WHERE userId = ? `)
|
||||
stmt.run(values)
|
||||
|
||||
return this.getFitnessProfileByUserId(userId)
|
||||
}
|
||||
|
||||
async deleteFitnessProfile(userId: string): Promise<boolean> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('DELETE FROM fitness_profiles WHERE userId = ?')
|
||||
const result = stmt.run(userId)
|
||||
return (result.changes || 0) > 0
|
||||
}
|
||||
|
||||
// Attendance operations
|
||||
async checkIn(clientId: string, type: 'gym' | 'class' | 'personal_training', notes?: string): Promise<Attendance> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const id = Math.random().toString(36).substr(2, 9)
|
||||
const now = new Date()
|
||||
|
||||
const attendance: Attendance = {
|
||||
id,
|
||||
clientId,
|
||||
checkInTime: now,
|
||||
type,
|
||||
notes,
|
||||
createdAt: now
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO attendance(id, clientId, checkInTime, type, notes, createdAt)
|
||||
VALUES(?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
attendance.id, attendance.clientId, attendance.checkInTime.toISOString(),
|
||||
attendance.type, attendance.notes, attendance.createdAt.toISOString()
|
||||
)
|
||||
|
||||
// Update client last visit
|
||||
this.db.prepare('UPDATE clients SET lastVisit = ? WHERE id = ?').run(
|
||||
now.toISOString(),
|
||||
clientId
|
||||
)
|
||||
|
||||
return attendance
|
||||
}
|
||||
|
||||
async checkOut(attendanceId: string): Promise<Attendance | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const now = new Date()
|
||||
const stmt = this.db.prepare('UPDATE attendance SET checkOutTime = ? WHERE id = ?')
|
||||
stmt.run(now.toISOString(), attendanceId)
|
||||
|
||||
return this.getAttendanceById(attendanceId)
|
||||
}
|
||||
|
||||
async getAttendanceById(id: string): Promise<Attendance | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE id = ?')
|
||||
const row = stmt.get(id)
|
||||
return row ? this.mapRowToAttendance(row) : null
|
||||
}
|
||||
|
||||
async getAttendanceHistory(clientId: string): Promise<Attendance[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? ORDER BY checkInTime DESC')
|
||||
const rows = stmt.all(clientId)
|
||||
return rows.map(row => this.mapRowToAttendance(row))
|
||||
}
|
||||
|
||||
async getAllAttendance(): Promise<Attendance[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance ORDER BY checkInTime DESC')
|
||||
const rows = stmt.all()
|
||||
return rows.map(row => this.mapRowToAttendance(row))
|
||||
}
|
||||
|
||||
async getActiveCheckIn(clientId: string): Promise<Attendance | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
const stmt = this.db.prepare('SELECT * FROM attendance WHERE clientId = ? AND checkOutTime IS NULL ORDER BY checkInTime DESC LIMIT 1')
|
||||
const row = stmt.get(clientId)
|
||||
return row ? this.mapRowToAttendance(row) : null
|
||||
}
|
||||
|
||||
// Helper methods to map database rows to entities
|
||||
private mapRowToUser(row: any): User {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
firstName: row.firstName,
|
||||
lastName: row.lastName,
|
||||
password: row.password,
|
||||
phone: row.phone,
|
||||
role: row.role,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private mapRowToClient(row: any): Client {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
membershipType: row.membershipType,
|
||||
membershipStatus: row.membershipStatus,
|
||||
joinDate: new Date(row.joinDate),
|
||||
lastVisit: row.lastVisit ? new Date(row.lastVisit) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
private mapRowToFitnessProfile(row: any): FitnessProfile {
|
||||
return {
|
||||
userId: row.userId,
|
||||
height: row.height,
|
||||
weight: row.weight,
|
||||
age: row.age,
|
||||
gender: row.gender,
|
||||
activityLevel: row.activityLevel,
|
||||
fitnessGoals: JSON.parse(row.fitnessGoals || '[]'),
|
||||
exerciseHabits: row.exerciseHabits,
|
||||
dietHabits: row.dietHabits,
|
||||
medicalConditions: row.medicalConditions,
|
||||
allergies: row.allergies,
|
||||
injuries: row.injuries,
|
||||
createdAt: new Date(row.createdAt),
|
||||
updatedAt: new Date(row.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private mapRowToAttendance(row: any): Attendance {
|
||||
return {
|
||||
id: row.id,
|
||||
clientId: row.clientId,
|
||||
checkInTime: new Date(row.checkInTime),
|
||||
checkOutTime: row.checkOutTime ? new Date(row.checkOutTime) : undefined,
|
||||
type: row.type,
|
||||
notes: row.notes,
|
||||
createdAt: new Date(row.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Recommendation operations
|
||||
async createRecommendation(data: Omit<Recommendation, 'createdAt' | 'approvedAt' | 'approvedBy'>): Promise<Recommendation> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const now = new Date()
|
||||
const recommendation: Recommendation = {
|
||||
...data,
|
||||
createdAt: now,
|
||||
status: data.status || 'pending'
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO recommendations (
|
||||
id, userId, fitnessProfileId, type, content,
|
||||
activityPlan, dietPlan, status, createdAt
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
recommendation.id, recommendation.userId, recommendation.fitnessProfileId,
|
||||
recommendation.type, recommendation.content, recommendation.activityPlan,
|
||||
recommendation.dietPlan, recommendation.status,
|
||||
recommendation.createdAt.toISOString()
|
||||
)
|
||||
|
||||
return recommendation
|
||||
}
|
||||
|
||||
async getRecommendationsByUserId(userId: string): Promise<Recommendation[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE userId = ? ORDER BY createdAt DESC')
|
||||
const rows = stmt.all(userId)
|
||||
|
||||
return rows.map(row => this.mapRowToRecommendation(row))
|
||||
}
|
||||
|
||||
async getAllRecommendations(): Promise<Recommendation[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM recommendations ORDER BY createdAt DESC')
|
||||
const rows = stmt.all()
|
||||
|
||||
return rows.map(row => this.mapRowToRecommendation(row))
|
||||
}
|
||||
|
||||
async updateRecommendation(id: string, updates: Partial<Recommendation>): Promise<Recommendation | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'userId')
|
||||
if (fields.length === 0) {
|
||||
const stmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
|
||||
const row = stmt.get(id)
|
||||
return row ? this.mapRowToRecommendation(row) : null
|
||||
}
|
||||
|
||||
const setClause = fields.map(field => `${field} = ?`).join(', ')
|
||||
const values = fields.map(field => {
|
||||
const val = (updates as any)[field]
|
||||
return val instanceof Date ? val.toISOString() : val
|
||||
})
|
||||
values.push(id)
|
||||
|
||||
const stmt = this.db.prepare(`UPDATE recommendations SET ${setClause} WHERE id = ?`)
|
||||
stmt.run(values)
|
||||
|
||||
const getStmt = this.db.prepare('SELECT * FROM recommendations WHERE id = ?')
|
||||
const row = getStmt.get(id)
|
||||
return row ? this.mapRowToRecommendation(row) : null
|
||||
}
|
||||
|
||||
async deleteRecommendation(id: string): Promise<boolean> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('DELETE FROM recommendations WHERE id = ?')
|
||||
const result = stmt.run(id)
|
||||
return (result.changes || 0) > 0
|
||||
}
|
||||
|
||||
private mapRowToRecommendation(row: any): Recommendation {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
fitnessProfileId: row.fitnessProfileId,
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
activityPlan: row.activityPlan,
|
||||
dietPlan: row.dietPlan,
|
||||
status: row.status,
|
||||
createdAt: new Date(row.createdAt),
|
||||
approvedAt: row.approvedAt ? new Date(row.approvedAt) : undefined,
|
||||
approvedBy: row.approvedBy
|
||||
}
|
||||
}
|
||||
|
||||
// Fitness Goals operations
|
||||
async createFitnessGoal(goalData: Omit<import('./types').FitnessGoal, 'createdAt' | 'updatedAt'>): Promise<import('./types').FitnessGoal> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const now = new Date()
|
||||
const goal: import('./types').FitnessGoal = {
|
||||
...goalData,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
`INSERT INTO fitness_goals (
|
||||
id, user_id, fitness_profile_id, goal_type, title, description,
|
||||
target_value, current_value, unit, start_date, target_date,
|
||||
completed_date, status, progress, priority, notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
|
||||
stmt.run(
|
||||
goal.id,
|
||||
goal.userId,
|
||||
goal.fitnessProfileId || null,
|
||||
goal.goalType,
|
||||
goal.title,
|
||||
goal.description || null,
|
||||
goal.targetValue || null,
|
||||
goal.currentValue || null,
|
||||
goal.unit || null,
|
||||
goal.startDate.toISOString(),
|
||||
goal.targetDate ? goal.targetDate.toISOString() : null,
|
||||
goal.completedDate ? goal.completedDate.toISOString() : null,
|
||||
goal.status,
|
||||
goal.progress,
|
||||
goal.priority,
|
||||
goal.notes || null,
|
||||
goal.createdAt.toISOString(),
|
||||
goal.updatedAt.toISOString()
|
||||
)
|
||||
|
||||
return goal
|
||||
}
|
||||
|
||||
async getFitnessGoalById(id: string): Promise<import('./types').FitnessGoal | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('SELECT * FROM fitness_goals WHERE id = ?')
|
||||
const row = stmt.get(id)
|
||||
|
||||
return row ? this.mapRowToFitnessGoal(row) : null
|
||||
}
|
||||
|
||||
async getFitnessGoalsByUserId(userId: string, status?: string): Promise<import('./types').FitnessGoal[]> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
let query = 'SELECT * FROM fitness_goals WHERE user_id = ?'
|
||||
const params: any[] = [userId]
|
||||
|
||||
if (status) {
|
||||
query += ' AND status = ?'
|
||||
params.push(status)
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC'
|
||||
|
||||
const stmt = this.db.prepare(query)
|
||||
const rows = stmt.all(...params)
|
||||
|
||||
return rows.map(row => this.mapRowToFitnessGoal(row))
|
||||
}
|
||||
|
||||
async updateFitnessGoal(id: string, updates: Partial<import('./types').FitnessGoal>): Promise<import('./types').FitnessGoal | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const fields = Object.keys(updates).filter(key => key !== 'id' && key !== 'createdAt')
|
||||
if (fields.length === 0) return this.getFitnessGoalById(id)
|
||||
|
||||
// Map camelCase to snake_case for database columns
|
||||
const columnMap: Record<string, string> = {
|
||||
userId: 'user_id',
|
||||
fitnessProfileId: 'fitness_profile_id',
|
||||
goalType: 'goal_type',
|
||||
targetValue: 'target_value',
|
||||
currentValue: 'current_value',
|
||||
startDate: 'start_date',
|
||||
targetDate: 'target_date',
|
||||
completedDate: 'completed_date',
|
||||
updatedAt: 'updated_at'
|
||||
}
|
||||
|
||||
const setClause = fields.map(field => `${columnMap[field] || field} = ?`).join(', ')
|
||||
const values = fields.map(field => {
|
||||
const val = (updates as any)[field]
|
||||
return val instanceof Date ? val.toISOString() : val
|
||||
})
|
||||
values.push(new Date().toISOString()) // updatedAt
|
||||
values.push(id)
|
||||
|
||||
const stmt = this.db.prepare(`UPDATE fitness_goals SET ${setClause}, updated_at = ? WHERE id = ?`)
|
||||
stmt.run(values)
|
||||
|
||||
return this.getFitnessGoalById(id)
|
||||
}
|
||||
|
||||
async deleteFitnessGoal(id: string): Promise<boolean> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const stmt = this.db.prepare('DELETE FROM fitness_goals WHERE id = ?')
|
||||
const result = stmt.run(id)
|
||||
return (result.changes || 0) > 0
|
||||
}
|
||||
|
||||
async updateGoalProgress(id: string, currentValue: number): Promise<import('./types').FitnessGoal | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
// Get the goal to calculate progress
|
||||
const goal = await this.getFitnessGoalById(id)
|
||||
if (!goal) return null
|
||||
|
||||
let progress = goal.progress
|
||||
if (goal.targetValue && goal.targetValue > 0) {
|
||||
progress = Math.min(100, (currentValue / goal.targetValue) * 100)
|
||||
}
|
||||
|
||||
const stmt = this.db.prepare(
|
||||
'UPDATE fitness_goals SET current_value = ?, progress = ?, updated_at = ? WHERE id = ?'
|
||||
)
|
||||
stmt.run(currentValue, progress, new Date().toISOString(), id)
|
||||
|
||||
return this.getFitnessGoalById(id)
|
||||
}
|
||||
|
||||
async completeGoal(id: string): Promise<import('./types').FitnessGoal | null> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
const now = new Date()
|
||||
const stmt = this.db.prepare(
|
||||
'UPDATE fitness_goals SET status = ?, progress = ?, completed_date = ?, updated_at = ? WHERE id = ?'
|
||||
)
|
||||
stmt.run('completed', 100, now.toISOString(), now.toISOString(), id)
|
||||
|
||||
return this.getFitnessGoalById(id)
|
||||
}
|
||||
|
||||
private mapRowToFitnessGoal(row: any): import('./types').FitnessGoal {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
fitnessProfileId: row.fitness_profile_id,
|
||||
goalType: row.goal_type,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
targetValue: row.target_value,
|
||||
currentValue: row.current_value,
|
||||
unit: row.unit,
|
||||
startDate: new Date(row.start_date),
|
||||
targetDate: row.target_date ? new Date(row.target_date) : undefined,
|
||||
completedDate: row.completed_date ? new Date(row.completed_date) : undefined,
|
||||
status: row.status,
|
||||
progress: row.progress,
|
||||
priority: row.priority,
|
||||
notes: row.notes,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
async getDashboardStats(): Promise<{
|
||||
totalUsers: number;
|
||||
activeClients: number;
|
||||
totalRevenue: number;
|
||||
revenueGrowth: number;
|
||||
}> {
|
||||
if (!this.db) throw new Error('Database not connected')
|
||||
|
||||
// Total Users
|
||||
const userCountStmt = this.db.prepare('SELECT COUNT(*) as count FROM users')
|
||||
const userCount = (userCountStmt.get() as any).count
|
||||
|
||||
// Active Clients
|
||||
const activeClientCountStmt = this.db.prepare("SELECT COUNT(*) as count FROM clients WHERE membershipStatus = 'active'")
|
||||
const activeClientCount = (activeClientCountStmt.get() as any).count
|
||||
|
||||
// Total Revenue (assuming payments table exists, handling if it's empty)
|
||||
// Note: We need to create the payments table first if it doesn't exist in createTables
|
||||
// For now, returning 0 if table doesn't exist or is empty
|
||||
let totalRevenue = 0
|
||||
let revenueGrowth = 0
|
||||
|
||||
try {
|
||||
const revenueStmt = this.db.prepare('SELECT SUM(amount) as total FROM payments WHERE status = "completed"')
|
||||
const revenueResult = revenueStmt.get() as any
|
||||
totalRevenue = revenueResult?.total || 0
|
||||
} catch (e) {
|
||||
// Table might not exist yet
|
||||
console.warn('Payments table query failed, returning 0 revenue')
|
||||
}
|
||||
|
||||
return {
|
||||
totalUsers: userCount,
|
||||
activeClients: activeClientCount,
|
||||
totalRevenue,
|
||||
revenueGrowth
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,20 @@
|
||||
import {
|
||||
User as SharedUser,
|
||||
Client,
|
||||
FitnessProfile,
|
||||
Attendance,
|
||||
Recommendation,
|
||||
FitnessGoal,
|
||||
} from "@fitai/shared";
|
||||
|
||||
// Database Entity Types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
export interface User extends SharedUser {
|
||||
// Explicitly include gymId so server imports can rely on it even if shared types lag behind build
|
||||
gymId?: string;
|
||||
password: string;
|
||||
phone?: string;
|
||||
role: "superAdmin" | "admin" | "trainer" | "client";
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
userId: string;
|
||||
membershipType: "basic" | "premium" | "vip";
|
||||
membershipStatus: "active" | "inactive" | "expired";
|
||||
joinDate: Date;
|
||||
lastVisit?: Date;
|
||||
}
|
||||
|
||||
export interface FitnessProfile {
|
||||
userId: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
age: string;
|
||||
gender: "male" | "female" | "other";
|
||||
activityLevel: "sedentary" | "light" | "moderate" | "active" | "very_active";
|
||||
fitnessGoals: string[];
|
||||
exerciseHabits: string;
|
||||
dietHabits: string;
|
||||
medicalConditions: string;
|
||||
allergies?: string;
|
||||
injuries?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
id: string;
|
||||
clientId: string;
|
||||
checkInTime: Date;
|
||||
checkOutTime?: Date;
|
||||
type: "gym" | "class" | "personal_training";
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
||||
content: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: "pending" | "approved" | "rejected" | "completed";
|
||||
createdAt: Date;
|
||||
approvedAt?: Date;
|
||||
approvedBy?: string;
|
||||
}
|
||||
|
||||
export interface FitnessGoal {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom";
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
startDate: Date;
|
||||
targetDate?: Date;
|
||||
completedDate?: Date;
|
||||
status: "active" | "completed" | "abandoned" | "paused";
|
||||
progress: number;
|
||||
priority: "low" | "medium" | "high";
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal };
|
||||
|
||||
// Database Interface - allows us to swap implementations
|
||||
export interface IDatabase {
|
||||
@@ -121,18 +55,21 @@ export interface IDatabase {
|
||||
|
||||
// Attendance operations
|
||||
checkIn(
|
||||
clientId: string,
|
||||
userId: string,
|
||||
type: "gym" | "class" | "personal_training",
|
||||
notes?: string,
|
||||
): Promise<Attendance>;
|
||||
checkOut(attendanceId: string): Promise<Attendance | null>;
|
||||
getAttendanceHistory(clientId: string): Promise<Attendance[]>;
|
||||
getAttendanceHistory(userId: string): Promise<Attendance[]>;
|
||||
getAllAttendance(): Promise<Attendance[]>;
|
||||
getActiveCheckIn(clientId: string): Promise<Attendance | null>;
|
||||
getActiveCheckIn(userId: string): Promise<Attendance | null>;
|
||||
|
||||
// Recommendation operations
|
||||
createRecommendation(
|
||||
recommendation: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">,
|
||||
recommendation: Omit<
|
||||
Recommendation,
|
||||
"createdAt" | "approvedAt" | "approvedBy"
|
||||
>,
|
||||
): Promise<Recommendation>;
|
||||
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
|
||||
getAllRecommendations(): Promise<Recommendation[]>;
|
||||
@@ -144,16 +81,22 @@ export interface IDatabase {
|
||||
|
||||
// Fitness Goals operations
|
||||
createFitnessGoal(
|
||||
goal: Omit<FitnessGoal, "createdAt" | "updatedAt">
|
||||
goal: Omit<FitnessGoal, "createdAt" | "updatedAt">,
|
||||
): Promise<FitnessGoal>;
|
||||
getFitnessGoalById(id: string): Promise<FitnessGoal | null>;
|
||||
getFitnessGoalsByUserId(userId: string, status?: string): Promise<FitnessGoal[]>;
|
||||
getFitnessGoalsByUserId(
|
||||
userId: string,
|
||||
status?: string,
|
||||
): Promise<FitnessGoal[]>;
|
||||
updateFitnessGoal(
|
||||
id: string,
|
||||
updates: Partial<FitnessGoal>
|
||||
updates: Partial<FitnessGoal>,
|
||||
): Promise<FitnessGoal | null>;
|
||||
deleteFitnessGoal(id: string): Promise<boolean>;
|
||||
updateGoalProgress(id: string, currentValue: number): Promise<FitnessGoal | null>;
|
||||
updateGoalProgress(
|
||||
id: string,
|
||||
currentValue: number,
|
||||
): Promise<FitnessGoal | null>;
|
||||
completeGoal(id: string): Promise<FitnessGoal | null>;
|
||||
|
||||
// Dashboard operations
|
||||
|
||||
@@ -1,62 +1,72 @@
|
||||
import { currentUser } from '@clerk/nextjs/server'
|
||||
import { IDatabase } from './database/types'
|
||||
import { currentUser } from "@clerk/nextjs/server";
|
||||
import type { IDatabase, User } from "./database/types";
|
||||
|
||||
export async function ensureUserSynced(userId: string, db: IDatabase) {
|
||||
const existingUser = await db.getUserById(userId)
|
||||
if (existingUser) return existingUser
|
||||
export async function ensureUserSynced(
|
||||
userId: string,
|
||||
db: IDatabase,
|
||||
): Promise<User | null> {
|
||||
const existingUser = await db.getUserById(userId);
|
||||
if (existingUser) return existingUser;
|
||||
|
||||
console.log('User not found in DB by ID, checking Clerk:', userId)
|
||||
const clerkUser = await currentUser()
|
||||
console.log("User not found in DB by ID, checking Clerk:", userId);
|
||||
const clerkUser = await currentUser();
|
||||
|
||||
if (!clerkUser || clerkUser.id !== userId) {
|
||||
// If we can't get the user from Clerk (e.g. running locally without full auth sync),
|
||||
// we might want to fail gracefully or throw.
|
||||
// For now, throw to be safe.
|
||||
throw new Error('Could not fetch Clerk user details')
|
||||
}
|
||||
if (!clerkUser || clerkUser.id !== userId) {
|
||||
// If we can't get the user from Clerk (e.g. running locally without full auth sync),
|
||||
// we might want to fail gracefully or throw.
|
||||
// For now, throw to be safe.
|
||||
throw new Error("Could not fetch Clerk user details");
|
||||
}
|
||||
|
||||
const email = clerkUser.emailAddresses[0]?.emailAddress
|
||||
if (!email) throw new Error('User has no email')
|
||||
const email = clerkUser.emailAddresses[0]?.emailAddress;
|
||||
if (!email) throw new Error("User has no email");
|
||||
|
||||
// Check if user exists by email (e.g. seeded user)
|
||||
const existingByEmail = await db.getUserByEmail(email)
|
||||
if (existingByEmail) {
|
||||
console.log('User found by email but ID mismatch. Migrating ID...', {
|
||||
oldId: existingByEmail.id,
|
||||
newId: userId
|
||||
})
|
||||
// Check if user exists by email (e.g. seeded user)
|
||||
const existingByEmail = await db.getUserByEmail(email);
|
||||
if (existingByEmail) {
|
||||
console.log("User found by email but ID mismatch. Migrating ID...", {
|
||||
oldId: existingByEmail.id,
|
||||
newId: userId,
|
||||
});
|
||||
|
||||
// Update the ID to match Clerk ID
|
||||
// We need to do this manually via SQL because IDatabase interface might not expose a direct ID update method easily
|
||||
// But we can use a raw query if we had access, or add a method.
|
||||
// Since we don't have direct access to `db.db` here (it's hidden behind IDatabase),
|
||||
// we should add a method to IDatabase or use a workaround.
|
||||
// Actually, `SQLiteDatabase` is what we have at runtime.
|
||||
// Let's assume we can cast it or add `updateUserId` to the interface.
|
||||
// Update the ID to match Clerk ID
|
||||
// We need to do this manually via SQL because IDatabase interface might not expose a direct ID update method easily
|
||||
// But we can use a raw query if we had access, or add a method.
|
||||
// Since we don't have direct access to `db.db` here (it's hidden behind IDatabase),
|
||||
// we should add a method to IDatabase or use a workaround.
|
||||
// Actually, `SQLiteDatabase` is what we have at runtime.
|
||||
// Let's assume we can cast it or add `updateUserId` to the interface.
|
||||
|
||||
// For now, let's try to update it using `updateUser` but `updateUser` usually updates fields based on ID.
|
||||
// We can't update the ID itself using `updateUser(id, { id: newId })` because `updateUser` implementation filters out `id` from updates.
|
||||
// For now, let's try to update it using `updateUser` but `updateUser` usually updates fields based on ID.
|
||||
// We can't update the ID itself using `updateUser(id, { id: newId })` because `updateUser` implementation filters out `id` from updates.
|
||||
|
||||
// We need to add a method to migrate user ID in IDatabase.
|
||||
// Or, strictly for this prototype, we can delete the old user and create a new one (DATA LOSS RISK).
|
||||
// But since it's a seeded super admin with no data, it's fine.
|
||||
// However, if they had clients, we'd lose the link.
|
||||
// We need to add a method to migrate user ID in IDatabase.
|
||||
// Or, strictly for this prototype, we can delete the old user and create a new one (DATA LOSS RISK).
|
||||
// But since it's a seeded super admin with no data, it's fine.
|
||||
// However, if they had clients, we'd lose the link.
|
||||
|
||||
// Let's add `migrateUserId(oldId, newId)` to IDatabase.
|
||||
await db.migrateUserId(existingByEmail.id, userId)
|
||||
return db.getUserById(userId)
|
||||
}
|
||||
// Let's add `migrateUserId(oldId, newId)` to IDatabase.
|
||||
await db.migrateUserId(existingByEmail.id, userId);
|
||||
return db.getUserById(userId);
|
||||
}
|
||||
|
||||
console.log('Creating new user from Clerk data:', userId)
|
||||
const user = await db.createUser({
|
||||
id: userId,
|
||||
email,
|
||||
firstName: clerkUser.firstName || '',
|
||||
lastName: clerkUser.lastName || '',
|
||||
password: '', // Managed by Clerk
|
||||
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
||||
role: (clerkUser.publicMetadata.role as 'admin' | 'client' | 'superAdmin') || 'client'
|
||||
})
|
||||
console.log("Creating new user from Clerk data:", userId);
|
||||
const user = await db.createUser({
|
||||
id: userId,
|
||||
email,
|
||||
firstName: clerkUser.firstName || "",
|
||||
lastName: clerkUser.lastName || "",
|
||||
password: "", // Managed by Clerk
|
||||
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
||||
gymId: (clerkUser.publicMetadata.gymId as string | undefined) || undefined,
|
||||
role: ((): any => {
|
||||
const r = clerkUser.publicMetadata.role as string | undefined;
|
||||
return r &&
|
||||
["superAdmin", "admin", "trainer", "client", "generalUser"].includes(r)
|
||||
? r
|
||||
: "client";
|
||||
})(),
|
||||
});
|
||||
|
||||
return user
|
||||
return user;
|
||||
}
|
||||
@@ -1,14 +1 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date) {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})
|
||||
}
|
||||
export { cn, formatDate } from "@fitai/shared"
|
||||
+11
-4
@@ -15,21 +15,28 @@
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "This app uses the camera to scan food barcodes and identify nutritional information."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"permissions": [
|
||||
"CAMERA"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font"
|
||||
"expo-font",
|
||||
"expo-barcode-scanner"
|
||||
],
|
||||
"scheme": "fitai"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+279
-138
@@ -8,7 +8,7 @@
|
||||
"name": "@fitai/mobile",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@clerk/clerk-expo": "^2.18.3",
|
||||
"@clerk/clerk-expo": "^2.19.11",
|
||||
"@expo/vector-icons": "^15.0.0",
|
||||
"@hookform/resolvers": "^3.3.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
@@ -19,10 +19,11 @@
|
||||
"ajv-keywords": "^5.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.23",
|
||||
"expo-auth-session": "^7.0.8",
|
||||
"expo-camera": "~17.0.0",
|
||||
"expo-auth-session": "^7.0.10",
|
||||
"expo-barcode-scanner": "^13.0.1",
|
||||
"expo-camera": "~17.0.9",
|
||||
"expo-constants": "^18.0.10",
|
||||
"expo-crypto": "^15.0.7",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "^15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
@@ -31,9 +32,9 @@
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-status-bar": "^3.0.8",
|
||||
"expo-web-browser": "^15.0.9",
|
||||
"expo-web-browser": "^15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
@@ -1562,15 +1563,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@clerk/clerk-expo": {
|
||||
"version": "2.18.3",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-expo/-/clerk-expo-2.18.3.tgz",
|
||||
"integrity": "sha512-lxlDiartLPhVjQiz88biCYa8+ZAv3dOOS0KXQnYn6M79UrO6cCcSO+o3QwEKMvwhiCEp0BcH7wnypU/GOXoB1Q==",
|
||||
"version": "2.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-expo/-/clerk-expo-2.19.11.tgz",
|
||||
"integrity": "sha512-zwCPDJm/WKe91rMGPequVKgGGWmUR/2OIR0IBqXg0hLaUFjEdce0hoB0QSVdc8TweC8djy+5vKVZIfQ2Tg6Vpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/clerk-js": "^5.105.1",
|
||||
"@clerk/clerk-react": "^5.53.8",
|
||||
"@clerk/shared": "^3.31.1",
|
||||
"@clerk/types": "^4.97.2",
|
||||
"@clerk/clerk-js": "^5.114.1",
|
||||
"@clerk/clerk-react": "^5.58.1",
|
||||
"@clerk/shared": "^3.39.0",
|
||||
"@clerk/types": "^4.101.6",
|
||||
"base-64": "^1.0.0",
|
||||
"react-native-url-polyfill": "2.0.0",
|
||||
"tslib": "2.8.1"
|
||||
@@ -1609,14 +1610,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/clerk-js": {
|
||||
"version": "5.105.1",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-js/-/clerk-js-5.105.1.tgz",
|
||||
"integrity": "sha512-3ObGAARYsDjN4KS/FmCYvHL4FJtWjUdlDinUX1p1tPkFopPypiK9yhU+HSadpFTseLecc5TBnvuotJAAkQ2gGQ==",
|
||||
"version": "5.114.1",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-js/-/clerk-js-5.114.1.tgz",
|
||||
"integrity": "sha512-l3A9UZKWOI7rCz/P9o2bS4hSTRLzPqr9eAPxhBFsfUsGgzlxQjVeyRQ2XgOXfvwsgekmulVHzlbvGZidT7JF6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@base-org/account": "2.0.1",
|
||||
"@clerk/localizations": "^3.27.0",
|
||||
"@clerk/shared": "^3.31.1",
|
||||
"@clerk/localizations": "^3.30.1",
|
||||
"@clerk/shared": "^3.39.0",
|
||||
"@coinbase/wallet-sdk": "4.3.0",
|
||||
"@emotion/cache": "11.11.0",
|
||||
"@emotion/react": "11.11.1",
|
||||
@@ -1636,8 +1637,7 @@
|
||||
"dequal": "2.0.3",
|
||||
"input-otp": "1.4.2",
|
||||
"qrcode.react": "4.2.0",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"swr": "2.3.4"
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
@@ -1664,12 +1664,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@clerk/clerk-react": {
|
||||
"version": "5.53.8",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.53.8.tgz",
|
||||
"integrity": "sha512-TOiYk31rQUL9JOKZr/fhajf+fQCHicy1J4Rxq7vqtjHseJsnIBjzTigjOap/w8PrDAF28O6dbPC5CA0Tp7Md8w==",
|
||||
"version": "5.58.1",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/clerk-react/-/clerk-react-5.58.1.tgz",
|
||||
"integrity": "sha512-jN6mfuqwZakm99CKRQlTahMZEa8qLOpr3Z4lG6XtyJfIcyADleOdRxuOXYoN9sV8ZhNfDDAs+eKjUeusIzPpbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "^3.31.1",
|
||||
"@clerk/shared": "^3.39.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1681,21 +1681,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/localizations": {
|
||||
"version": "3.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/localizations/-/localizations-3.27.0.tgz",
|
||||
"integrity": "sha512-XQrzOtON32dXlTHEUwL0utTro6MePSM1gljCjRcY6RKh36I2BcuuG2XVu6K2HIvBL+Ef+nWF8+aU4+zc14nVcQ==",
|
||||
"version": "3.30.1",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/localizations/-/localizations-3.30.1.tgz",
|
||||
"integrity": "sha512-CHatnmQsGggsBMOO3k82oEh9E7j8Xs/vjFtx+hwGwM/qcJ6CEPmP69QKeRBfAXY5PcJinAuyYmFEqMEFvbyjSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/types": "^4.97.2"
|
||||
"@clerk/types": "^4.101.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/shared": {
|
||||
"version": "3.31.1",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.31.1.tgz",
|
||||
"integrity": "sha512-mqxZqlzLJYJxA+ryLzhwFR0eO73teAvRd+wvA8bLUZLYvCRFvaiHsB9dEvbo9Z5bMYdq3NPwnx2uljMuu/tiQw==",
|
||||
"version": "3.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.39.0.tgz",
|
||||
"integrity": "sha512-9kqqXGMPAdMQ7SXo5ZwUhbzbLLQeLp/1jdb8FQS5qlhmL0S0bAYKcyDjcmMB8xZPXNc7vZJRT72QHsGSlUAJxw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1723,12 +1723,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clerk/types": {
|
||||
"version": "4.97.2",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.97.2.tgz",
|
||||
"integrity": "sha512-xnJq3xzpmuuDnNnWuUMKJLPPkaEaLDM0kiv2Hm0gKIcL1+1P3VaGf2vL9roIhmhLswB2PUwtVvZKBmGjT5yOVw==",
|
||||
"version": "4.101.6",
|
||||
"resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.6.tgz",
|
||||
"integrity": "sha512-Ah6R65loy5Aq1jBpWo3x01IOlYJfgVn4LVAkSXKVQDtehn8w1bT2uJ7BTa2zH72A8F4K2HivkmMQa+v/37tLFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clerk/shared": "^3.31.1"
|
||||
"@clerk/shared": "^3.39.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
@@ -2153,40 +2153,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config": {
|
||||
"version": "12.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.10.tgz",
|
||||
"integrity": "sha512-lJMof5Nqakq1DxGYlghYB/ogSBjmv4Fxn1ovyDmcjlRsQdFCXgu06gEUogkhPtc9wBt9WlTTfqENln5HHyLW6w==",
|
||||
"version": "12.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.12.tgz",
|
||||
"integrity": "sha512-X2MW86+ulLpMGvdgfvpl2EOBAKUlwvnvoPwdaZeeyWufGopn1nTUeh4C9gMsplDaP1kXv9sLXVhOoUoX6g9PvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "~7.10.4",
|
||||
"@expo/config-plugins": "~54.0.2",
|
||||
"@expo/config-types": "^54.0.8",
|
||||
"@expo/json-file": "^10.0.7",
|
||||
"@expo/config-plugins": "~54.0.3",
|
||||
"@expo/config-types": "^54.0.10",
|
||||
"@expo/json-file": "^10.0.8",
|
||||
"deepmerge": "^4.3.1",
|
||||
"getenv": "^2.0.0",
|
||||
"glob": "^10.4.2",
|
||||
"glob": "^13.0.0",
|
||||
"require-from-string": "^2.0.2",
|
||||
"resolve-from": "^5.0.0",
|
||||
"resolve-workspace-root": "^2.0.0",
|
||||
"semver": "^7.6.0",
|
||||
"slugify": "^1.3.4",
|
||||
"sucrase": "3.35.0"
|
||||
"sucrase": "~3.35.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins": {
|
||||
"version": "54.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.2.tgz",
|
||||
"integrity": "sha512-jD4qxFcURQUVsUFGMcbo63a/AnviK8WUGard+yrdQE3ZrB/aurn68SlApjirQQLEizhjI5Ar2ufqflOBlNpyPg==",
|
||||
"version": "54.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.3.tgz",
|
||||
"integrity": "sha512-tBIUZIxLQfCu5jmqTO+UOeeDUGIB0BbK6xTMkPRObAXRQeTLPPfokZRCo818d2owd+Bcmq1wBaDz0VY3g+glfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config-types": "^54.0.8",
|
||||
"@expo/config-types": "^54.0.9",
|
||||
"@expo/json-file": "~10.0.7",
|
||||
"@expo/plist": "^0.4.7",
|
||||
"@expo/sdk-runtime-versions": "^1.0.0",
|
||||
"chalk": "^4.1.2",
|
||||
"debug": "^4.3.5",
|
||||
"getenv": "^2.0.0",
|
||||
"glob": "^10.4.2",
|
||||
"glob": "^13.0.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.5.4",
|
||||
"slash": "^3.0.0",
|
||||
@@ -2196,20 +2196,57 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins/node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"license": "ISC",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
|
||||
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minimatch": "^10.1.1",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins/node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins/node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-plugins/node_modules/path-scurry": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -2228,9 +2265,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config-types": {
|
||||
"version": "54.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.8.tgz",
|
||||
"integrity": "sha512-lyIn/x/Yz0SgHL7IGWtgTLg6TJWC9vL7489++0hzCHZ4iGjVcfZmPTUfiragZ3HycFFj899qN0jlhl49IHa94A==",
|
||||
"version": "54.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz",
|
||||
"integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@expo/config/node_modules/@babel/code-frame": {
|
||||
@@ -2243,20 +2280,57 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config/node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"license": "ISC",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz",
|
||||
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minimatch": "^10.1.1",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
"path-scurry": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config/node_modules/lru-cache": {
|
||||
"version": "11.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz",
|
||||
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config/node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/config/node_modules/path-scurry": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -2336,9 +2410,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/env": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.7.tgz",
|
||||
"integrity": "sha512-BNETbLEohk3HQ2LxwwezpG8pq+h7Fs7/vAMP3eAtFT1BCpprLYoBBFZH7gW4aqGfqOcVP4Lc91j014verrYNGg==",
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.8.tgz",
|
||||
"integrity": "sha512-5VQD6GT8HIMRaSaB5JFtOXuvfDVU80YtZIuUT/GDhUF782usIXY13Tn3IdDz1Tm/lqA9qnRZQ1BF4t7LlvdJPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.0.0",
|
||||
@@ -2433,9 +2507,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/json-file": {
|
||||
"version": "10.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.7.tgz",
|
||||
"integrity": "sha512-z2OTC0XNO6riZu98EjdNHC05l51ySeTto6GP7oSQrCvQgG9ARBwD1YvMQaVZ9wU7p/4LzSf1O7tckL3B45fPpw==",
|
||||
"version": "10.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.8.tgz",
|
||||
"integrity": "sha512-9LOTh1PgKizD1VXfGQ88LtDH0lRwq9lsTb4aichWTWSWqy3Ugfkhfm3BhzBIkJJfQQ5iJu3m/BoRlEIjoCGcnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "~7.10.4",
|
||||
@@ -2878,6 +2952,27 @@
|
||||
"integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -5021,9 +5116,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/abitype": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.1.1.tgz",
|
||||
"integrity": "sha512-Loe5/6tAgsBukY95eGaPSDmQHIjRZYQq8PB1MpsNccDIK8WiV+Uw6WzaIXipvaxTEL2yEB0OpEaQv3gs8pkS9Q==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.2.tgz",
|
||||
"integrity": "sha512-4DOIMWscIB3j8hboLAUjLZCE8TMLdgecBpHFumfU4PdO/C1SBCVx4Nu1wPYXaL2iK8B0Jk3tiwnDLCpUtm3fZg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/wevm"
|
||||
@@ -7162,9 +7257,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-application": {
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.7.tgz",
|
||||
"integrity": "sha512-Jt1/qqnoDUbZ+bK91+dHaZ1vrPDtRBOltRa681EeedkisqguuEeUx4UHqwVyDK2oHWsK6lO3ojetoA4h8OmNcg==",
|
||||
"version": "7.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz",
|
||||
"integrity": "sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
@@ -7186,16 +7281,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-auth-session": {
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.9.tgz",
|
||||
"integrity": "sha512-mPSwaRWOJYas160lXi5P/7BkLy0xbh+er+IMmAYHqf2+iz2WWs9W/4lMAklQVJG2mCyOZi24XrkffvB2izCa1g==",
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.10.tgz",
|
||||
"integrity": "sha512-XDnKkudvhHSKkZfJ+KkodM+anQcrxB71i+h0kKabdLa5YDXTQ81aC38KRc3TMqmnBDHAu0NpfbzEVd9WDFY3Qg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-application": "~7.0.7",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-crypto": "~15.0.7",
|
||||
"expo-linking": "~8.0.9",
|
||||
"expo-web-browser": "~15.0.9",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-constants": "~18.0.11",
|
||||
"expo-crypto": "~15.0.8",
|
||||
"expo-linking": "~8.0.10",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -7203,6 +7298,18 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-barcode-scanner": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-barcode-scanner/-/expo-barcode-scanner-13.0.1.tgz",
|
||||
"integrity": "sha512-xBGLT1An2gpAMIQRTLU3oHydKohX8r8F9/ait1Fk9Vgd0GraFZbP4IiT7nHMlaw4H6E7Muucf7vXpGV6u7d4HQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-image-loader": "~4.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-camera": {
|
||||
"version": "17.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.9.tgz",
|
||||
@@ -7224,13 +7331,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "18.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz",
|
||||
"integrity": "sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw==",
|
||||
"version": "18.0.11",
|
||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.11.tgz",
|
||||
"integrity": "sha512-xnfrfZ7lHjb+03skhmDSYeFF7OU2K3Xn/lAeP+7RhkV2xp2f5RCKtOUYajCnYeZesvMrsUxOsbGOP2JXSOH3NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config": "~12.0.10",
|
||||
"@expo/env": "~2.0.7"
|
||||
"@expo/config": "~12.0.11",
|
||||
"@expo/env": "~2.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -7238,9 +7345,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-crypto": {
|
||||
"version": "15.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.7.tgz",
|
||||
"integrity": "sha512-FUo41TwwGT2e5rA45PsjezI868Ch3M6wbCZsmqTWdF/hr+HyPcrp1L//dsh/hsrsyrQdpY/U96Lu71/wXePJeg==",
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.8.tgz",
|
||||
"integrity": "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0"
|
||||
@@ -7282,6 +7389,15 @@
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-image-loader": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-4.7.0.tgz",
|
||||
"integrity": "sha512-cx+MxxsAMGl9AiWnQUzrkJMJH4eNOGlu7XkLGnAXSJrRoIiciGaKqzeaD326IyCTV+Z1fXvIliSgNW+DscvD8g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-linear-gradient": {
|
||||
"version": "15.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.7.tgz",
|
||||
@@ -7294,12 +7410,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-linking": {
|
||||
"version": "8.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.9.tgz",
|
||||
"integrity": "sha512-a0UHhlVyfwIbn8b1PSFPoFiIDJeps2iEq109hVH3CHd0CMKuRxFfNio9Axe2BjXhiJCYWR4OV1iIyzY/GjiVkQ==",
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.10.tgz",
|
||||
"integrity": "sha512-0EKtn4Sk6OYmb/5ZqK8riO0k1Ic+wyT3xExbmDvUYhT7p/cKqlVUExMuOIAt3Cx3KUUU1WCgGmdd493W/D5XjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-constants": "~18.0.11",
|
||||
"invariant": "^2.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -7440,9 +7556,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-secure-store": {
|
||||
"version": "15.0.7",
|
||||
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.7.tgz",
|
||||
"integrity": "sha512-9q7+G1Zxr5P6J5NRIlm86KulvmYwc6UnQlYPjQLDu1drDnerz6AT6l884dPu29HgtDTn4rR0heYeeGFhMKM7/Q==",
|
||||
"version": "15.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz",
|
||||
"integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
@@ -7471,9 +7587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-web-browser": {
|
||||
"version": "15.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.9.tgz",
|
||||
"integrity": "sha512-Dj8kNFO+oXsxqCDNlUT/GhOrJnm10kAElH++3RplLydogFm5jTzXYWDEeNIDmV+F+BzGYs+sIhxiBf7RyaxXZg==",
|
||||
"version": "15.0.10",
|
||||
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz",
|
||||
"integrity": "sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -7619,6 +7735,23 @@
|
||||
"asap": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
|
||||
@@ -12764,17 +12897,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
|
||||
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.2",
|
||||
"commander": "^4.0.0",
|
||||
"glob": "^10.3.10",
|
||||
"lines-and-columns": "^1.1.6",
|
||||
"mz": "^2.7.0",
|
||||
"pirates": "^4.0.1",
|
||||
"tinyglobby": "^0.2.11",
|
||||
"ts-interface-checker": "^0.1.9"
|
||||
},
|
||||
"bin": {
|
||||
@@ -12794,26 +12927,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase/node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"foreground-child": "^3.1.0",
|
||||
"jackspeak": "^3.1.2",
|
||||
"minimatch": "^9.0.4",
|
||||
"minipass": "^7.1.2",
|
||||
"package-json-from-dist": "^1.0.0",
|
||||
"path-scurry": "^1.11.1"
|
||||
},
|
||||
"bin": {
|
||||
"glob": "dist/esm/bin.mjs"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -13014,6 +13127,34 @@
|
||||
"integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
@@ -13407,9 +13548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/viem": {
|
||||
"version": "2.38.6",
|
||||
"resolved": "https://registry.npmjs.org/viem/-/viem-2.38.6.tgz",
|
||||
"integrity": "sha512-aqO6P52LPXRjdnP6rl5Buab65sYa4cZ6Cpn+k4OLOzVJhGIK8onTVoKMFMT04YjDfyDICa/DZyV9HmvLDgcjkw==",
|
||||
"version": "2.41.2",
|
||||
"resolved": "https://registry.npmjs.org/viem/-/viem-2.41.2.tgz",
|
||||
"integrity": "sha512-LYliajglBe1FU6+EH9mSWozp+gRA/QcHfxeD9Odf83AdH5fwUS7DroH4gHvlv6Sshqi1uXrYFA2B/EOczxd15g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/clerk-expo": "^2.18.3",
|
||||
"@clerk/clerk-expo": "^2.19.11",
|
||||
"@expo/vector-icons": "^15.0.0",
|
||||
"@hookform/resolvers": "^3.3.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
@@ -25,10 +25,11 @@
|
||||
"ajv-keywords": "^5.1.0",
|
||||
"axios": "^1.6.0",
|
||||
"expo": "~54.0.23",
|
||||
"expo-auth-session": "^7.0.8",
|
||||
"expo-camera": "~17.0.0",
|
||||
"expo-auth-session": "^7.0.10",
|
||||
"expo-barcode-scanner": "^13.0.1",
|
||||
"expo-camera": "~17.0.9",
|
||||
"expo-constants": "^18.0.10",
|
||||
"expo-crypto": "^15.0.7",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "^15.0.7",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
@@ -37,9 +38,9 @@
|
||||
"expo-router": "~6.0.14",
|
||||
"expo-secure-store": "~15.0.7",
|
||||
"expo-status-bar": "^3.0.8",
|
||||
"expo-web-browser": "^15.0.9",
|
||||
"expo-web-browser": "^15.0.10",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.47.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -18,6 +18,32 @@ export default function OnboardingScreen() {
|
||||
const { getToken } = useAuth();
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [gyms, setGyms] = useState<
|
||||
Array<{ id: string; name: string; location?: string }>
|
||||
>([]);
|
||||
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
|
||||
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadGyms = async () => {
|
||||
try {
|
||||
setGymsLoading(true);
|
||||
const token = await getToken();
|
||||
const res = await fetch("/api/gyms", {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setGyms(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch gyms:", e);
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
}
|
||||
};
|
||||
loadGyms();
|
||||
}, []);
|
||||
const [fitnessProfile, setFitnessProfile] = useState({
|
||||
height: "",
|
||||
weight: "",
|
||||
@@ -41,6 +67,26 @@ export default function OnboardingScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error("Authentication token not available");
|
||||
}
|
||||
|
||||
// If gym was selected or cleared, patch user's gym selection first
|
||||
// selectedGymId: string gym id, or null to proceed without gym
|
||||
try {
|
||||
await fetch("/api/users/gym", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ gymId: selectedGymId }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Failed to update gym selection:", e);
|
||||
}
|
||||
|
||||
const fitnessData = {
|
||||
clientId: user.id,
|
||||
height: parseFloat(fitnessProfile.height),
|
||||
@@ -59,10 +105,6 @@ export default function OnboardingScreen() {
|
||||
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
|
||||
};
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error("Authentication token not available");
|
||||
}
|
||||
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
||||
router.replace("/(tabs)");
|
||||
} catch (error) {
|
||||
@@ -213,6 +255,55 @@ export default function OnboardingScreen() {
|
||||
placeholder="Number of workouts per week"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Select a Gym</Text>
|
||||
{gymsLoading ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.levelButton,
|
||||
selectedGymId === null && styles.selectedButton,
|
||||
]}
|
||||
onPress={() => setSelectedGymId(null)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.levelButtonText,
|
||||
selectedGymId === null && styles.selectedButtonText,
|
||||
]}
|
||||
>
|
||||
Proceed without gym
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{gyms.map((gym) => (
|
||||
<TouchableOpacity
|
||||
key={gym.id}
|
||||
style={[
|
||||
styles.levelButton,
|
||||
selectedGymId === gym.id && styles.selectedButton,
|
||||
]}
|
||||
onPress={() => setSelectedGymId(gym.id)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.levelButtonText,
|
||||
selectedGymId === gym.id && styles.selectedButtonText,
|
||||
]}
|
||||
>
|
||||
{gym.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.submitButton}
|
||||
onPress={handleSubmit}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { QuickActionGrid } from "../../components/QuickActionGrid";
|
||||
import { TrackMealModal } from "../../components/TrackMealModal";
|
||||
import { AddWaterModal } from "../../components/AddWaterModal";
|
||||
import { HydrationWidget } from "../../components/HydrationWidget";
|
||||
import { ScanFoodModal } from "../../components/ScanFoodModal";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
export default function HomeScreen() {
|
||||
@@ -16,6 +17,7 @@ export default function HomeScreen() {
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [trackMealModalVisible, setTrackMealModalVisible] = useState(false);
|
||||
const [addWaterModalVisible, setAddWaterModalVisible] = useState(false);
|
||||
const [scanFoodModalVisible, setScanFoodModalVisible] = useState(false);
|
||||
const [calories, setCalories] = useState(0);
|
||||
const [waterIntake, setWaterIntake] = useState(0);
|
||||
|
||||
@@ -51,6 +53,11 @@ export default function HomeScreen() {
|
||||
setWaterIntake(0);
|
||||
};
|
||||
|
||||
const handleAddScannedFood = (scannedCalories: number) => {
|
||||
setCalories(prev => prev + scannedCalories);
|
||||
setScanFoodModalVisible(false);
|
||||
};
|
||||
|
||||
const resetAllCounters = async () => {
|
||||
setCalories(0);
|
||||
setWaterIntake(0);
|
||||
@@ -134,6 +141,7 @@ export default function HomeScreen() {
|
||||
<QuickActionGrid
|
||||
onTrackMealPress={() => setTrackMealModalVisible(true)}
|
||||
onAddWaterPress={() => setAddWaterModalVisible(true)}
|
||||
onScanFoodPress={() => setScanFoodModalVisible(true)}
|
||||
/>
|
||||
|
||||
<TrackMealModal
|
||||
@@ -150,6 +158,12 @@ export default function HomeScreen() {
|
||||
onResetData={handleResetWater}
|
||||
/>
|
||||
|
||||
<ScanFoodModal
|
||||
visible={scanFoodModalVisible}
|
||||
onClose={() => setScanFoodModalVisible(false)}
|
||||
onAddFood={handleAddScannedFood}
|
||||
/>
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
|
||||
import { useUser, useClerk } from "@clerk/clerk-expo";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
Alert,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from "react-native";
|
||||
import { useUser, useClerk, useAuth } from "@clerk/clerk-expo";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
@@ -21,14 +30,55 @@ export default function ProfileScreen() {
|
||||
};
|
||||
|
||||
const confirmSignOut = () => {
|
||||
Alert.alert(
|
||||
"Sign Out",
|
||||
"Are you sure you want to sign out?",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
|
||||
]
|
||||
);
|
||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
|
||||
]);
|
||||
};
|
||||
|
||||
// Gym selection state and handlers
|
||||
const { getToken } = useAuth();
|
||||
const [gyms, setGyms] = useState<
|
||||
Array<{ id: string; name: string; location?: string }>
|
||||
>([]);
|
||||
const [gymsLoading, setGymsLoading] = useState(false);
|
||||
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
|
||||
|
||||
const loadGyms = async () => {
|
||||
try {
|
||||
setGymsLoading(true);
|
||||
const token = await getToken();
|
||||
const res = await fetch("/api/gyms", {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
setGyms(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch gyms:", err);
|
||||
} finally {
|
||||
setGymsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGym = async () => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
await fetch("/api/users/gym", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ gymId: selectedGymId }),
|
||||
});
|
||||
Alert.alert(
|
||||
"Success",
|
||||
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to update gym selection:", err);
|
||||
Alert.alert("Error", "Failed to update gym selection");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -48,7 +98,9 @@ export default function ProfileScreen() {
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.name}>{user?.fullName || "User"}</Text>
|
||||
<Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
|
||||
<Text style={styles.email}>
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</Text>
|
||||
<View style={styles.memberBadge}>
|
||||
<Text style={styles.memberText}>Premium Member</Text>
|
||||
</View>
|
||||
@@ -59,39 +111,144 @@ export default function ProfileScreen() {
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Account</Text>
|
||||
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
||||
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/personal-details')}>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() => router.push("/personal-details")}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.05)']}
|
||||
colors={["rgba(59, 130, 246, 0.1)", "rgba(59, 130, 246, 0.05)"]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="person-outline" size={20} color={theme.colors.primary} />
|
||||
<Ionicons
|
||||
name="person-outline"
|
||||
size={20}
|
||||
color={theme.colors.primary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Personal Details</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/fitness-profile')}>
|
||||
<TouchableOpacity
|
||||
style={styles.infoRow}
|
||||
onPress={() => router.push("/fitness-profile")}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(16, 185, 129, 0.1)', 'rgba(16, 185, 129, 0.05)']}
|
||||
colors={["rgba(16, 185, 129, 0.1)", "rgba(16, 185, 129, 0.05)"]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="fitness-outline" size={20} color={theme.colors.success} />
|
||||
<Ionicons
|
||||
name="fitness-outline"
|
||||
size={20}
|
||||
color={theme.colors.success}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Fitness Profile</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(245, 158, 11, 0.1)', 'rgba(245, 158, 11, 0.05)']}
|
||||
colors={["rgba(245, 158, 11, 0.1)", "rgba(245, 158, 11, 0.05)"]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="notifications-outline" size={20} color={theme.colors.warning} />
|
||||
<Ionicons
|
||||
name="notifications-outline"
|
||||
size={20}
|
||||
color={theme.colors.warning}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Notifications</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Gym Selection */}
|
||||
<View
|
||||
style={[styles.infoCard, theme.shadows.subtle, { marginTop: 12 }]}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<Text style={styles.infoLabel}>Gym</Text>
|
||||
<TouchableOpacity onPress={loadGyms}>
|
||||
<Text style={{ color: theme.colors.primary }}>
|
||||
Refresh Gyms
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{gymsLoading ? (
|
||||
<ActivityIndicator style={{ padding: 12 }} />
|
||||
) : (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={{ paddingHorizontal: 12, paddingBottom: 12 }}
|
||||
>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.infoRow,
|
||||
selectedGymId === null && {
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
]}
|
||||
onPress={() => setSelectedGymId(null)}
|
||||
>
|
||||
<Text style={styles.infoLabel}>Proceed without gym</Text>
|
||||
</TouchableOpacity>
|
||||
{gyms.map((gym) => (
|
||||
<TouchableOpacity
|
||||
key={gym.id}
|
||||
style={[
|
||||
styles.infoRow,
|
||||
selectedGymId === gym.id && {
|
||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
||||
},
|
||||
]}
|
||||
onPress={() => setSelectedGymId(gym.id)}
|
||||
>
|
||||
<Text style={styles.infoLabel}>{gym.name}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View style={{ padding: 12 }}>
|
||||
<AnimatedButton
|
||||
title="Apply Gym Selection"
|
||||
onPress={handleApplyGym}
|
||||
variant="primary"
|
||||
style={{ marginTop: 4 }}
|
||||
icon={
|
||||
<Ionicons
|
||||
name="checkmark-outline"
|
||||
size={20}
|
||||
color={theme.colors.white}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
@@ -99,24 +256,43 @@ export default function ProfileScreen() {
|
||||
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(139, 92, 246, 0.1)', 'rgba(139, 92, 246, 0.05)']}
|
||||
colors={["rgba(139, 92, 246, 0.1)", "rgba(139, 92, 246, 0.05)"]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="help-circle-outline" size={20} color={theme.colors.secondary} />
|
||||
<Ionicons
|
||||
name="help-circle-outline"
|
||||
size={20}
|
||||
color={theme.colors.secondary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Help Center</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity style={styles.infoRow}>
|
||||
<LinearGradient
|
||||
colors={['rgba(107, 114, 128, 0.1)', 'rgba(107, 114, 128, 0.05)']}
|
||||
colors={[
|
||||
"rgba(107, 114, 128, 0.1)",
|
||||
"rgba(107, 114, 128, 0.05)",
|
||||
]}
|
||||
style={styles.iconContainer}
|
||||
>
|
||||
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.gray600} />
|
||||
<Ionicons
|
||||
name="shield-checkmark-outline"
|
||||
size={20}
|
||||
color={theme.colors.gray600}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<Text style={styles.infoLabel}>Privacy & Security</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={theme.colors.gray400}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
@@ -145,13 +321,13 @@ const styles = StyleSheet.create({
|
||||
paddingBottom: 30,
|
||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||
borderBottomRightRadius: theme.borderRadius.xl,
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
},
|
||||
profileCard: {
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
position: "relative",
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
@@ -159,28 +335,28 @@ const styles = StyleSheet.create({
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
placeholderAvatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderWidth: 4,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
editBadge: {
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: theme.colors.white,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
@@ -188,23 +364,23 @@ const styles = StyleSheet.create({
|
||||
elevation: 3,
|
||||
},
|
||||
name: {
|
||||
fontSize: theme.typography.fontSize['2xl'],
|
||||
fontSize: theme.typography.fontSize["2xl"],
|
||||
fontWeight: theme.typography.fontWeight.bold,
|
||||
color: theme.colors.white,
|
||||
marginBottom: 4,
|
||||
},
|
||||
email: {
|
||||
fontSize: theme.typography.fontSize.sm,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
color: "rgba(255, 255, 255, 0.8)",
|
||||
marginBottom: 12,
|
||||
},
|
||||
memberBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
||||
},
|
||||
memberText: {
|
||||
color: theme.colors.white,
|
||||
@@ -234,16 +410,16 @@ const styles = StyleSheet.create({
|
||||
borderColor: theme.colors.gray100,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: 12,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginRight: 12,
|
||||
},
|
||||
infoLabel: {
|
||||
@@ -261,7 +437,7 @@ const styles = StyleSheet.create({
|
||||
marginTop: 8,
|
||||
},
|
||||
version: {
|
||||
textAlign: 'center',
|
||||
textAlign: "center",
|
||||
marginTop: 24,
|
||||
color: theme.colors.gray400,
|
||||
fontSize: theme.typography.fontSize.xs,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { theme } from "../../styles/theme";
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: string;
|
||||
@@ -141,7 +141,7 @@ export default function RecommendationsScreen() {
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>Daily Advice</Text>
|
||||
<Text style={styles.content}>{item.content}</Text>
|
||||
<Text style={styles.content}>{item.recommendationText}</Text>
|
||||
|
||||
{item.activityPlan && (
|
||||
<>
|
||||
|
||||
@@ -39,9 +39,10 @@ function QuickActionItem({ icon, label, gradient, onPress }: QuickActionProps) {
|
||||
interface QuickActionGridProps {
|
||||
onTrackMealPress?: () => void;
|
||||
onAddWaterPress?: () => void;
|
||||
onScanFoodPress?: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActionGridProps) {
|
||||
export function QuickActionGrid({ onTrackMealPress, onAddWaterPress, onScanFoodPress }: QuickActionGridProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.sectionTitle}>Quick Actions</Text>
|
||||
@@ -67,6 +68,7 @@ export function QuickActionGrid({ onTrackMealPress, onAddWaterPress }: QuickActi
|
||||
icon="scan"
|
||||
label="Scan Food"
|
||||
gradient={theme.gradients.purple}
|
||||
onPress={onScanFoodPress}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Modal, TouchableOpacity, TextInput, Alert } from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { theme } from '../styles/theme';
|
||||
|
||||
interface ScanFoodModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onAddFood: (calories: number) => void;
|
||||
}
|
||||
|
||||
// Mock food database
|
||||
const FOOD_DATABASE: { [key: string]: { name: string; calories: number; servingSize: string } } = {
|
||||
'0123456789': { name: 'Apple', calories: 95, servingSize: '1 medium' },
|
||||
'9876543210': { name: 'Banana', calories: 105, servingSize: '1 medium' },
|
||||
'5555555555': { name: 'Protein Bar', calories: 200, servingSize: '1 bar' },
|
||||
'1111111111': { name: 'Greek Yogurt', calories: 150, servingSize: '1 cup' },
|
||||
};
|
||||
|
||||
export function ScanFoodModal({ visible, onClose, onAddFood }: ScanFoodModalProps) {
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [scanned, setScanned] = useState(false);
|
||||
const [foodData, setFoodData] = useState<{ name: string; calories: number; servingSize: string } | null>(null);
|
||||
const [servings, setServings] = useState('1');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setScanned(false);
|
||||
setFoodData(null);
|
||||
setServings('1');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleBarCodeScanned = ({ data }: { data: string }) => {
|
||||
if (scanned) return;
|
||||
|
||||
setScanned(true);
|
||||
|
||||
// Look up food in database
|
||||
const food = FOOD_DATABASE[data];
|
||||
|
||||
if (food) {
|
||||
setFoodData(food);
|
||||
} else {
|
||||
// Mock data for unknown barcodes
|
||||
setFoodData({
|
||||
name: 'Unknown Food',
|
||||
calories: 150,
|
||||
servingSize: '1 serving'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (foodData) {
|
||||
const totalCalories = foodData.calories * parseFloat(servings || '1');
|
||||
onAddFood(totalCalories);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRescan = () => {
|
||||
setScanned(false);
|
||||
setFoodData(null);
|
||||
setServings('1');
|
||||
};
|
||||
|
||||
if (!permission) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide">
|
||||
<View style={styles.permissionContainer}>
|
||||
<View style={styles.permissionContent}>
|
||||
<Ionicons name="camera-outline" size={64} color={theme.colors.primary} />
|
||||
<Text style={styles.permissionTitle}>Camera Permission Required</Text>
|
||||
<Text style={styles.permissionText}>
|
||||
We need access to your camera to scan food barcodes.
|
||||
</Text>
|
||||
<TouchableOpacity onPress={requestPermission} style={styles.permissionButtonContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.primary}
|
||||
style={styles.permissionButton}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>Grant Permission</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={onClose} style={styles.cancelButton}>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide">
|
||||
<View style={styles.container}>
|
||||
{!foodData ? (
|
||||
<>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Scan Food Barcode</Text>
|
||||
<View style={{ width: 28 }} />
|
||||
</View>
|
||||
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||
barcodeScannerSettings={{
|
||||
barcodeTypes: ['ean13', 'ean8', 'upc_a', 'upc_e', 'code128', 'code39'],
|
||||
}}
|
||||
>
|
||||
<View style={styles.scanOverlay}>
|
||||
<View style={styles.scanFrame} />
|
||||
<Text style={styles.scanText}>Position barcode within frame</Text>
|
||||
</View>
|
||||
</CameraView>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.resultContainer}>
|
||||
<View style={styles.resultHeader}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<Ionicons name="close" size={28} color={theme.colors.gray900} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.resultTitle}>Food Details</Text>
|
||||
<View style={{ width: 28 }} />
|
||||
</View>
|
||||
|
||||
<View style={styles.foodCard}>
|
||||
<View style={styles.foodIconContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.foodIcon}
|
||||
>
|
||||
<Ionicons name="restaurant" size={32} color="#fff" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
|
||||
<Text style={styles.foodName}>{foodData.name}</Text>
|
||||
<Text style={styles.servingSize}>{foodData.servingSize}</Text>
|
||||
|
||||
<View style={styles.caloriesBadge}>
|
||||
<Text style={styles.caloriesValue}>{foodData.calories}</Text>
|
||||
<Text style={styles.caloriesLabel}>kcal per serving</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.servingsContainer}>
|
||||
<Text style={styles.label}>Number of Servings</Text>
|
||||
<View style={styles.servingsInput}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setServings(String(Math.max(0.5, parseFloat(servings || '1') - 0.5)))}
|
||||
style={styles.servingsButton}
|
||||
>
|
||||
<Ionicons name="remove" size={20} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
<TextInput
|
||||
style={styles.servingsValue}
|
||||
value={servings}
|
||||
onChangeText={setServings}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setServings(String(parseFloat(servings || '1') + 0.5))}
|
||||
style={styles.servingsButton}
|
||||
>
|
||||
<Ionicons name="add" size={20} color={theme.colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.totalCalories}>
|
||||
<Text style={styles.totalLabel}>Total Calories</Text>
|
||||
<Text style={styles.totalValue}>
|
||||
{Math.round(foodData.calories * parseFloat(servings || '1'))} kcal
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity onPress={handleRescan} style={styles.rescanButton}>
|
||||
<Text style={styles.rescanButtonText}>Scan Again</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={handleConfirm} style={styles.confirmButtonContainer}>
|
||||
<LinearGradient
|
||||
colors={theme.gradients.success}
|
||||
style={styles.confirmButton}
|
||||
>
|
||||
<Text style={styles.confirmButtonText}>Add to Diary</Text>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
camera: {
|
||||
flex: 1,
|
||||
},
|
||||
scanOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
scanFrame: {
|
||||
width: 250,
|
||||
height: 250,
|
||||
borderWidth: 2,
|
||||
borderColor: '#fff',
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
scanText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginTop: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
resultContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
resultHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
paddingTop: 60,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
resultTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray900,
|
||||
},
|
||||
foodCard: {
|
||||
margin: 20,
|
||||
padding: 24,
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 24,
|
||||
alignItems: 'center',
|
||||
...theme.shadows.medium,
|
||||
},
|
||||
foodIconContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
foodIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
foodName: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray900,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
servingSize: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.gray600,
|
||||
marginBottom: 24,
|
||||
},
|
||||
caloriesBadge: {
|
||||
backgroundColor: theme.colors.gray50,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
caloriesValue: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.primary,
|
||||
},
|
||||
caloriesLabel: {
|
||||
fontSize: 14,
|
||||
color: theme.colors.gray600,
|
||||
marginTop: 4,
|
||||
},
|
||||
servingsContainer: {
|
||||
width: '100%',
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.gray700,
|
||||
marginBottom: 12,
|
||||
},
|
||||
servingsInput: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
servingsButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: theme.colors.gray100,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
servingsValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray900,
|
||||
textAlign: 'center',
|
||||
minWidth: 60,
|
||||
},
|
||||
totalCalories: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 24,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.gray200,
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.gray700,
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray900,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
rescanButton: {
|
||||
flex: 1,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 20,
|
||||
backgroundColor: theme.colors.gray100,
|
||||
alignItems: 'center',
|
||||
},
|
||||
rescanButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray700,
|
||||
},
|
||||
confirmButtonContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
confirmButton: {
|
||||
paddingVertical: 16,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
confirmButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
permissionContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
permissionContent: {
|
||||
backgroundColor: '#fff',
|
||||
borderRadius: 24,
|
||||
padding: 32,
|
||||
margin: 20,
|
||||
alignItems: 'center',
|
||||
...theme.shadows.strong,
|
||||
},
|
||||
permissionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: theme.colors.gray900,
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
permissionText: {
|
||||
fontSize: 16,
|
||||
color: theme.colors.gray600,
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
permissionButtonContainer: {
|
||||
width: '100%',
|
||||
marginBottom: 12,
|
||||
},
|
||||
permissionButton: {
|
||||
paddingVertical: 16,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
permissionButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: '#fff',
|
||||
},
|
||||
cancelButton: {
|
||||
paddingVertical: 12,
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: theme.colors.gray600,
|
||||
},
|
||||
});
|
||||
@@ -1,29 +1,30 @@
|
||||
export const API_BASE_URL = __DEV__
|
||||
? 'https://694d46f62d87.ngrok-free.app'
|
||||
: 'https://your-production-url.com'
|
||||
? "https://a4db649a0973.ngrok-free.app"
|
||||
: "https://your-production-url.com";
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
AUTH: {
|
||||
LOGIN: '/api/auth/login',
|
||||
REGISTER: '/api/auth/register',
|
||||
LOGIN: "/api/auth/login",
|
||||
REGISTER: "/api/auth/register",
|
||||
},
|
||||
PROFILE: {
|
||||
FITNESS: '/api/profile/fitness',
|
||||
FITNESS: "/api/profile/fitness",
|
||||
},
|
||||
CLIENTS: '/api/clients',
|
||||
USERS: '/api/users',
|
||||
CLIENTS: "/api/clients",
|
||||
USERS: "/api/users",
|
||||
GYMS: "/api/gyms",
|
||||
ATTENDANCE: {
|
||||
CHECK_IN: '/api/attendance/check-in',
|
||||
CHECK_OUT: '/api/attendance/check-out',
|
||||
HISTORY: '/api/attendance/history',
|
||||
CHECK_IN: "/api/attendance/check-in",
|
||||
CHECK_OUT: "/api/attendance/check-out",
|
||||
HISTORY: "/api/attendance/history",
|
||||
},
|
||||
RECOMMENDATIONS: '/api/recommendations',
|
||||
RECOMMENDATIONS: "/api/recommendations",
|
||||
FITNESS_GOALS: {
|
||||
LIST: '/api/fitness-goals',
|
||||
CREATE: '/api/fitness-goals',
|
||||
LIST: "/api/fitness-goals",
|
||||
CREATE: "/api/fitness-goals",
|
||||
GET: (id: string) => `/api/fitness-goals/${id}`,
|
||||
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
|
||||
DELETE: (id: string) => `/api/fitness-goals/${id}`,
|
||||
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
|
||||
},
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
# Analysis and Next Steps
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Architecture
|
||||
- **Monorepo Structure**: The project is well-structured as a monorepo with `apps/admin` (Next.js), `apps/mobile` (Expo), and shared packages (`database`, `shared`).
|
||||
- **Database Inconsistency**: There is a significant architectural inconsistency in database access.
|
||||
- `packages/database` defines a Drizzle ORM schema and exports a Drizzle instance.
|
||||
- `apps/admin` implements a custom `SQLiteDatabase` class (`src/lib/database/sqlite.ts`) that uses `better-sqlite3` directly with raw SQL queries and manual object mapping.
|
||||
- This leads to code duplication, potential schema drift (if Drizzle schema changes but raw SQL doesn't), and loss of Drizzle's type safety and convenience features.
|
||||
|
||||
### Code Quality
|
||||
- **Type Safety**:
|
||||
- `UserManagement.tsx` defines local interfaces (e.g., `User`) that duplicate the schema definitions.
|
||||
- Shared types should be utilized from `@fitai/shared` or `@fitai/database`.
|
||||
- **Configuration**:
|
||||
- `apps/mobile/src/config/api.ts` contains hardcoded URLs (e.g., ngrok) which is brittle for development and production.
|
||||
- API routes in `apps/admin` contain `console.log` statements that should be removed or replaced with a proper logging solution.https://5e424f097c8b.ngrok-free.app
|
||||
- **Error Handling**:
|
||||
- Basic error handling exists in API routes, but could be standardized (e.g., custom error classes, consistent error response format).
|
||||
|
||||
### Testing
|
||||
- **Missing Tests**: While `jest` is configured in `apps/admin`, there are no actual test files in `src`. The critical business logic in `src/lib/database` and API routes is currently untested.
|
||||
|
||||
## Suggested Next Steps
|
||||
|
||||
### 1. Unify Database Access (High Priority)
|
||||
Refactor `apps/admin` to use the Drizzle ORM instance from `@fitai/database` instead of the custom `SQLiteDatabase` class.
|
||||
- **Benefits**: Removes ~800 lines of raw SQL code, ensures type safety, reduces maintenance burden, and leverages Drizzle's features.
|
||||
- **Action**: Replace `src/lib/database/sqlite.ts` methods with Drizzle queries using the schema from `@fitai/database`.
|
||||
|
||||
### 2. Implement Testing Strategy (High Priority)
|
||||
Start adding tests for critical paths.
|
||||
- **Unit Tests**: Add Jest tests for the database logic (if keeping the custom class temporarily) or the service layer.
|
||||
- **Integration Tests**: Test API routes (`/api/attendance/*`, `/api/users`) to ensure they handle requests correctly.
|
||||
- **Action**: Create `__tests__` directories in `apps/admin/src` and add initial tests for attendance flows.
|
||||
|
||||
### 3. Improve Configuration Management (Medium Priority)
|
||||
- **Mobile**: Use environment variables (e.g., `EXPO_PUBLIC_API_URL`) for the API base URL in the mobile app instead of hardcoding.
|
||||
- **Admin**: Ensure all secrets and config values are strictly in `.env` files.
|
||||
|
||||
### 4. Enhance Type Safety (Medium Priority)
|
||||
- **Shared Types**: Move the `User` interface and other shared entities from `UserManagement.tsx` to `@fitai/shared` or use the inferred types from Drizzle schema in `@fitai/database`.
|
||||
- **Action**: Refactor components to import types from the shared package.
|
||||
|
||||
### 5. Cleanup and Standardization (Low Priority)
|
||||
- Remove `console.log` from production code.
|
||||
- Standardize API error responses.
|
||||
|
||||
## Immediate Action Items
|
||||
1. **Create a reproduction test case** for the current attendance flow to ensure no regressions during refactoring.
|
||||
2. **Refactor one module** (e.g., Attendance) to use Drizzle ORM as a proof of concept for the migration.
|
||||
3. **Extract types** from `UserManagement.tsx` to `@fitai/shared`.
|
||||
@@ -0,0 +1,10 @@
|
||||
## onboarding
|
||||
|
||||
on first login user can choose gym or to proceed without gym, gyms are connected to admin account. gyms are presented as a grid with proceed without gym option. user can select gym at a later date in profile screen on the mobile app where we should have select gym option.
|
||||
admins can add/invite trainers, and trainers can add/invite clients.
|
||||
admins can see only trainers and clients from their gym as well statistic for their gym.
|
||||
user invited by admins and trainers are asignt to their gym.
|
||||
superAdmin can see all data.
|
||||
|
||||
|
||||
we should use clerk invitation functionalyty to invite users.
|
||||
Generated
+5
-2
@@ -991,6 +991,7 @@
|
||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.3",
|
||||
"@typescript-eslint/types": "8.46.3",
|
||||
@@ -1196,6 +1197,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1628,6 +1630,7 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -2792,8 +2795,7 @@
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
@@ -3132,6 +3134,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
Generated
+4
-1
@@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"better-sqlite3": "12.4.1",
|
||||
"drizzle-orm": "^0.44.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -907,6 +907,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -946,6 +947,7 @@
|
||||
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -1219,6 +1221,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"better-sqlite3": "12.4.1",
|
||||
"@types/better-sqlite3": "^7.6.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import * as schema from './schema'
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Configurable database path with intelligent defaults
|
||||
const dbPath = process.env.DATABASE_URL ||
|
||||
(process.env.NODE_ENV === 'production'
|
||||
? './data/fitai.db'
|
||||
: '../../apps/admin/data/fitai.db')
|
||||
const dbPath = "./data/fitai.db";
|
||||
|
||||
const sqlite = new Database(dbPath)
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
const sqlite = new Database(dbPath);
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
export * from './schema'
|
||||
export { eq, and, or, desc, asc } from 'drizzle-orm'
|
||||
export * from "./schema";
|
||||
export { eq, and, or, desc, asc, sql } from "drizzle-orm";
|
||||
@@ -6,10 +6,32 @@ export const users = sqliteTable("users", {
|
||||
firstName: text("first_name").notNull(),
|
||||
lastName: text("last_name").notNull(),
|
||||
password: text("password"), // Optional - Clerk handles authentication
|
||||
role: text("role", { enum: ["superAdmin", "admin", "trainer", "client"] })
|
||||
role: text("role", {
|
||||
enum: ["superAdmin", "admin", "trainer", "client", "generalUser"],
|
||||
})
|
||||
.notNull()
|
||||
.default("client"),
|
||||
phone: text("phone"),
|
||||
// Remove direct foreign key reference to avoid circular dependency; validate at application level
|
||||
gymId: text("gym_id"),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const gyms = sqliteTable("gyms", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
location: text("location"),
|
||||
status: text("status", { enum: ["active", "inactive"] })
|
||||
.notNull()
|
||||
.default("active"),
|
||||
adminUserId: text("admin_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
@@ -74,9 +96,9 @@ export const payments = sqliteTable("payments", {
|
||||
|
||||
export const attendance = sqliteTable("attendance", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id")
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: "cascade" }),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
checkInTime: integer("check_in_time", { mode: "timestamp" }).notNull(),
|
||||
checkOutTime: integer("check_out_time", { mode: "timestamp" }),
|
||||
type: text("type", { enum: ["gym", "class", "personal_training"] })
|
||||
@@ -116,15 +138,7 @@ export const fitnessProfiles = sqliteTable("fitness_profiles", {
|
||||
gender: text("gender", {
|
||||
enum: ["male", "female", "other", "prefer_not_to_say"],
|
||||
}),
|
||||
fitnessGoal: text("fitness_goal", {
|
||||
enum: [
|
||||
"weight_loss",
|
||||
"muscle_gain",
|
||||
"endurance",
|
||||
"flexibility",
|
||||
"general_fitness",
|
||||
],
|
||||
}),
|
||||
fitnessGoals: text("fitness_goals", { mode: "json" }).$type<string[]>(),
|
||||
activityLevel: text("activity_level", {
|
||||
enum: [
|
||||
"sedentary",
|
||||
@@ -152,7 +166,7 @@ export const fitnessGoals = sqliteTable("fitness_goals", {
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
fitnessProfileId: text("fitness_profile_id").references(
|
||||
() => fitnessProfiles.id,
|
||||
{ onDelete: "cascade" }
|
||||
{ onDelete: "cascade" },
|
||||
),
|
||||
|
||||
// Goal details
|
||||
@@ -206,6 +220,24 @@ export const fitnessGoals = sqliteTable("fitness_goals", {
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
// Removed local invitations table; Clerk invitations are the source of truth
|
||||
|
||||
export const trainerClients = sqliteTable("trainer_clients", {
|
||||
id: text("id").primaryKey(),
|
||||
trainerUserId: text("trainer_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
clientUserId: text("client_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
gymId: text("gym_id")
|
||||
.notNull()
|
||||
.references(() => gyms.id, { onDelete: "cascade" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date()),
|
||||
});
|
||||
|
||||
export const recommendations = sqliteTable("recommendations", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
@@ -251,4 +283,3 @@ export type FitnessGoal = typeof fitnessGoals.$inferSelect;
|
||||
export type NewFitnessGoal = typeof fitnessGoals.$inferInsert;
|
||||
export type Recommendation = typeof recommendations.$inferSelect;
|
||||
export type NewRecommendation = typeof recommendations.$inferInsert;
|
||||
|
||||
Generated
+21
@@ -8,12 +8,33 @@
|
||||
"name": "@fitai/shared",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwind-merge": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
|
||||
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/dcastil"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,155 @@
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
role: 'admin' | 'trainer' | 'client'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
password?: string;
|
||||
phone?: string;
|
||||
role: "superAdmin" | "admin" | "trainer" | "client";
|
||||
gymId?: string;
|
||||
imageUrl?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
id: string
|
||||
userId: string
|
||||
user: User
|
||||
membershipType: 'basic' | 'premium' | 'vip'
|
||||
membershipStatus: 'active' | 'inactive' | 'suspended'
|
||||
joinDate: Date
|
||||
lastVisit?: Date
|
||||
emergencyContact: {
|
||||
name: string
|
||||
phone: string
|
||||
relationship: string
|
||||
}
|
||||
id: string;
|
||||
userId: string;
|
||||
user?: User;
|
||||
membershipType: "basic" | "premium" | "vip";
|
||||
membershipStatus: "active" | "inactive" | "suspended" | "expired";
|
||||
joinDate: Date;
|
||||
lastVisit?: Date;
|
||||
emergencyContact?: {
|
||||
name: string;
|
||||
phone: string;
|
||||
relationship: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string
|
||||
clientId: string
|
||||
client: Client
|
||||
amount: number
|
||||
currency: string
|
||||
status: 'pending' | 'completed' | 'failed' | 'refunded'
|
||||
paymentMethod: 'cash' | 'card' | 'bank_transfer'
|
||||
dueDate: Date
|
||||
paidAt?: Date
|
||||
description: string
|
||||
export interface FitnessProfile {
|
||||
id: string;
|
||||
userId: string;
|
||||
height: string;
|
||||
weight: string;
|
||||
age: string;
|
||||
gender: "male" | "female" | "other";
|
||||
activityLevel:
|
||||
| "sedentary"
|
||||
| "lightly_active"
|
||||
| "moderately_active"
|
||||
| "very_active"
|
||||
| "extremely_active";
|
||||
fitnessGoals: string[];
|
||||
exerciseHabits: string;
|
||||
dietHabits: string;
|
||||
medicalConditions: string;
|
||||
allergies?: string;
|
||||
injuries?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Attendance {
|
||||
id: string
|
||||
clientId: string
|
||||
client: Client
|
||||
checkInTime: Date
|
||||
checkOutTime?: Date
|
||||
type: 'gym' | 'class' | 'personal_training'
|
||||
notes?: string
|
||||
id: string;
|
||||
userId: string;
|
||||
clientId?: string;
|
||||
client?: Client;
|
||||
checkInTime: Date;
|
||||
checkOutTime?: Date;
|
||||
type: "gym" | "class" | "personal_training";
|
||||
notes?: string;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export interface Recommendation {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
||||
recommendationText: string;
|
||||
activityPlan?: string;
|
||||
dietPlan?: string;
|
||||
status: "pending" | "approved" | "rejected" | "completed";
|
||||
createdAt: Date;
|
||||
approvedAt?: Date;
|
||||
approvedBy?: string;
|
||||
}
|
||||
|
||||
export interface FitnessGoal {
|
||||
id: string;
|
||||
userId: string;
|
||||
fitnessProfileId?: string;
|
||||
goalType:
|
||||
| "weight_target"
|
||||
| "strength_milestone"
|
||||
| "endurance_target"
|
||||
| "flexibility_goal"
|
||||
| "habit_building"
|
||||
| "custom";
|
||||
title: string;
|
||||
description?: string;
|
||||
targetValue?: number;
|
||||
currentValue?: number;
|
||||
unit?: string;
|
||||
startDate: Date;
|
||||
targetDate?: Date;
|
||||
completedDate?: Date;
|
||||
status: "active" | "completed" | "abandoned" | "paused";
|
||||
progress: number;
|
||||
priority: "low" | "medium" | "high";
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Payment {
|
||||
id: string;
|
||||
clientId: string;
|
||||
client: Client;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: "pending" | "completed" | "failed" | "refunded";
|
||||
paymentMethod: "cash" | "card" | "bank_transfer";
|
||||
dueDate: Date;
|
||||
paidAt?: Date;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
userId: string
|
||||
title: string
|
||||
message: string
|
||||
type: 'payment_reminder' | 'attendance' | 'promotion' | 'system'
|
||||
read: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: "payment_reminder" | "attendance" | "promotion" | "system";
|
||||
read: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface Gym {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
status: "active" | "inactive";
|
||||
adminUserId: string;
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
inviterUserId: string;
|
||||
inviteeEmail: string;
|
||||
roleAssigned: "trainer" | "client" | "admin";
|
||||
gymId: string;
|
||||
token: string;
|
||||
status: "sent" | "accepted" | "expired";
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface TrainerClient {
|
||||
id: string;
|
||||
trainerUserId: string;
|
||||
clientUserId: string;
|
||||
gymId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
export const formatDate = (date: Date): string => {
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export const formatDate = (date: Date | string): string => {
|
||||
const d = new Date(date)
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
export const formatCurrency = (
|
||||
|
||||
Whitespace-only changes.
Reference in new issue
Block a user