Compare commits
No commits in common. "339d798a884b411efa2f325c510d9c251a1e9a37" and "868eaa5e3d130cadc95dfdd631ba0d589f4e53cb" have entirely different histories.
339d798a88
...
868eaa5e3d
Binary file not shown.
@ -1,84 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,166 +0,0 @@
|
|||||||
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,59 +1,25 @@
|
|||||||
import { auth } from "@clerk/nextjs/server";
|
import { auth } from '@clerk/nextjs/server'
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from 'next/server'
|
||||||
import { getDatabase } from "@/lib/database";
|
import { getDatabase } from '@/lib/database'
|
||||||
import { ensureUserSynced } from "@/lib/sync-user";
|
import { ensureUserSynced } from '@/lib/sync-user'
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth()
|
||||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||||
|
|
||||||
const db = await getDatabase();
|
const db = await getDatabase()
|
||||||
const user = await ensureUserSynced(userId, db);
|
const user = await ensureUserSynced(userId, db)
|
||||||
|
|
||||||
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
|
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
|
||||||
return new NextResponse("Forbidden", { status: 403 });
|
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.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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
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,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { getDatabase } from "../../../lib/database/index";
|
import { getDatabase } from "../../../lib/database/index";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||||
import { db as rawDb, sql } from "@fitai/database";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -12,47 +11,9 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
let users = await db.getAllUsers();
|
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) {
|
if (role) {
|
||||||
users = users.filter((user) => user.role === 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(
|
const usersWithClients = await Promise.all(
|
||||||
users.map(async (user) => {
|
users.map(async (user) => {
|
||||||
@ -84,50 +45,29 @@ export async function GET(request: NextRequest) {
|
|||||||
const weekAgo = new Date();
|
const weekAgo = new Date();
|
||||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||||
checkInsThisWeek = attendanceHistory.filter(
|
checkInsThisWeek = attendanceHistory.filter(
|
||||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
a => new Date(a.checkInTime) >= weekAgo
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// Calculate check-ins in last 30 days
|
// Calculate check-ins in last 30 days
|
||||||
const monthAgo = new Date();
|
const monthAgo = new Date();
|
||||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||||
checkInsThisMonth = attendanceHistory.filter(
|
checkInsThisMonth = attendanceHistory.filter(
|
||||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
a => new Date(a.checkInTime) >= monthAgo
|
||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...userWithoutPassword,
|
...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,
|
client,
|
||||||
isCheckedIn,
|
isCheckedIn,
|
||||||
checkInTime,
|
checkInTime,
|
||||||
lastCheckInTime,
|
lastCheckInTime,
|
||||||
checkInsThisWeek,
|
checkInsThisWeek,
|
||||||
checkInsThisMonth,
|
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 });
|
return NextResponse.json({ users: usersWithClients });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Get users error:", error);
|
console.error("Get users error:", error);
|
||||||
@ -148,12 +88,12 @@ export async function POST(request: NextRequest) {
|
|||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
|
|
||||||
// Get current user to check role
|
// 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.
|
// 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),
|
// 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.
|
// we might need to rely on the user being synced.
|
||||||
// Let's assume the user calling this API is already in our DB.
|
// 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?
|
// 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.
|
// 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.
|
// For this step, let's assume we can get the user.
|
||||||
@ -165,10 +105,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const currentUser = await db.getUserById(clerkUserId);
|
const currentUser = await db.getUserById(clerkUserId);
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Current user not found in database" }, { status: 403 });
|
||||||
{ error: "Current user not found in database" },
|
|
||||||
{ status: 403 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@ -186,14 +123,14 @@ export async function POST(request: NextRequest) {
|
|||||||
superAdmin: ["admin", "trainer", "client"],
|
superAdmin: ["admin", "trainer", "client"],
|
||||||
admin: ["trainer", "client"],
|
admin: ["trainer", "client"],
|
||||||
trainer: ["client"],
|
trainer: ["client"],
|
||||||
client: [],
|
client: []
|
||||||
};
|
};
|
||||||
|
|
||||||
const userRole = currentUser.role as keyof typeof allowed;
|
const userRole = currentUser.role as keyof typeof allowed;
|
||||||
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
|
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `You are not authorized to create a ${role}` },
|
{ error: `You are not authorized to create a ${role}` },
|
||||||
{ status: 403 },
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,24 +152,20 @@ export async function POST(request: NextRequest) {
|
|||||||
publicMetadata: {
|
publicMetadata: {
|
||||||
role,
|
role,
|
||||||
},
|
},
|
||||||
ignoreExisting: true, // Don't fail if invite exists
|
ignoreExisting: true // Don't fail if invite exists
|
||||||
});
|
});
|
||||||
} catch (clerkError: any) {
|
} catch (clerkError: any) {
|
||||||
console.error("Clerk invitation error:", clerkError);
|
console.error("Clerk invitation error:", clerkError);
|
||||||
// If user already exists in Clerk, we might want to handle it.
|
// 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.
|
// 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(
|
return NextResponse.json(
|
||||||
{ error: "User already exists in Clerk system" },
|
{ error: "User already exists in Clerk system" },
|
||||||
{ status: 409 },
|
{ status: 409 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{ error: "Failed to send invitation: " + (clerkError.message || "Unknown error") },
|
||||||
error:
|
|
||||||
"Failed to send invitation: " +
|
|
||||||
(clerkError.message || "Unknown error"),
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -249,19 +182,16 @@ export async function POST(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// If creating a client, create the client record too
|
// If creating a client, create the client record too
|
||||||
if (role === "client") {
|
if (role === 'client') {
|
||||||
await db.createClient({
|
await db.createClient({
|
||||||
userId: newUserId.id,
|
userId: newUserId.id,
|
||||||
membershipType: "basic",
|
membershipType: 'basic',
|
||||||
membershipStatus: "active",
|
membershipStatus: 'active',
|
||||||
joinDate: new Date(),
|
joinDate: new Date()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json({ userId: newUserId.id, message: "Invitation sent" }, { status: 201 });
|
||||||
{ userId: newUserId.id, message: "Invitation sent" },
|
|
||||||
{ status: 201 },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create user error:", error);
|
console.error("Create user error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -275,16 +205,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { id, email, firstName, lastName, role, phone, gymId } = body;
|
const { id, email, firstName, lastName, role, phone } = body;
|
||||||
console.log("PUT /api/users received body:", {
|
|
||||||
id,
|
|
||||||
email,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
role,
|
|
||||||
phone,
|
|
||||||
gymId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -293,43 +214,12 @@ export async function PUT(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate requester
|
// Get existing user
|
||||||
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);
|
const existingUser = await db.getUserById(id);
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
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
|
// Check if email is being changed and if it's already taken
|
||||||
if (email && email !== existingUser.email) {
|
if (email && email !== existingUser.email) {
|
||||||
const userWithEmail = await db.getUserByEmail(email);
|
const userWithEmail = await db.getUserByEmail(email);
|
||||||
@ -341,86 +231,16 @@ export async function PUT(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Clerk publicMetadata (role/gymId) to propagate via webhook
|
// Update user
|
||||||
// Note: Only update metadata when a change is requested
|
await db.updateUser(id, {
|
||||||
try {
|
email: email || existingUser.email,
|
||||||
const client = await clerkClient();
|
firstName: firstName || existingUser.firstName,
|
||||||
const publicMetadata: Record<string, unknown> = {};
|
lastName: lastName || existingUser.lastName,
|
||||||
console.log("PUT /api/users preparing Clerk metadata update:", {
|
role: role || existingUser.role,
|
||||||
targetUserId: id,
|
|
||||||
role,
|
|
||||||
gymId,
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Update user error:", error);
|
console.error("Update user error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@ -78,31 +78,15 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine role & gym from metadata
|
// Determine role from metadata or default to 'client'
|
||||||
const role =
|
const role =
|
||||||
(public_metadata?.role as
|
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
|
||||||
| "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
|
// Insert user into database with Clerk's user ID
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO users (id, email, first_name, last_name, password, phone, role, gym_id, created_at, updated_at)
|
INSERT INTO users (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
stmt.run(
|
stmt.run(
|
||||||
@ -113,57 +97,11 @@ export async function POST(req: Request) {
|
|||||||
"", // Clerk handles authentication
|
"", // Clerk handles authentication
|
||||||
null, // phone
|
null, // phone
|
||||||
role,
|
role,
|
||||||
gymId,
|
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If this is a client invited by a trainer, create trainer-client link
|
console.log(`✅ User ${id} created in database`);
|
||||||
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();
|
db.close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -186,21 +124,15 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine role & gym from metadata
|
// Determine role from metadata
|
||||||
const role =
|
const role =
|
||||||
(public_metadata?.role as
|
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
|
||||||
| "superAdmin"
|
|
||||||
| "admin"
|
|
||||||
| "trainer"
|
|
||||||
| "client"
|
|
||||||
| "generalUser") || "client";
|
|
||||||
let gymId = (public_metadata?.gymId as string | null) ?? null;
|
|
||||||
|
|
||||||
// Update user in database
|
// Update user in database
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET email = ?, first_name = ?, last_name = ?, role = ?, gym_id = ?, updated_at = ?
|
SET email = ?, firstName = ?, lastName = ?, role = ?, updatedAt = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@ -209,33 +141,10 @@ export async function POST(req: Request) {
|
|||||||
first_name || "",
|
first_name || "",
|
||||||
last_name || "",
|
last_name || "",
|
||||||
role,
|
role,
|
||||||
gymId,
|
|
||||||
now,
|
now,
|
||||||
id,
|
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`);
|
console.log(`✅ User ${id} updated in database`);
|
||||||
db.close();
|
db.close();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -2,485 +2,174 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import {
|
import { Database, Download, RefreshCw, AlertTriangle, Check, Loader2 } from "lucide-react";
|
||||||
Database,
|
|
||||||
Download,
|
|
||||||
RefreshCw,
|
|
||||||
AlertTriangle,
|
|
||||||
Check,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useUser } from "@clerk/nextjs";
|
|
||||||
|
|
||||||
interface Backup {
|
interface Backup {
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
|
||||||
|
|
||||||
interface Gym {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
location?: string | null;
|
|
||||||
status: "active" | "inactive";
|
|
||||||
adminUserId: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { user } = useUser();
|
const [backups, setBackups] = useState<Backup[]>([]);
|
||||||
const [backups, setBackups] = useState<Backup[]>([]);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loading, setLoading] = useState(true);
|
const [creatingBackup, setCreatingBackup] = useState(false);
|
||||||
const [creatingBackup, setCreatingBackup] = useState(false);
|
const [restoring, setRestoring] = useState<string | null>(null);
|
||||||
const [restoring, setRestoring] = useState<string | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||||
const [message, setMessage] = useState<{
|
|
||||||
type: "success" | "error";
|
|
||||||
text: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Gym picker state
|
const fetchBackups = async () => {
|
||||||
const [gyms, setGyms] = useState<Gym[]>([]);
|
try {
|
||||||
const [gymsLoading, setGymsLoading] = useState<boolean>(true);
|
const response = await axios.get("/api/admin/backups");
|
||||||
const [gymMessage, setGymMessage] = useState<{
|
setBackups(response.data);
|
||||||
type: "success" | "error";
|
} catch (error) {
|
||||||
text: string;
|
console.error("Failed to fetch backups:", error);
|
||||||
} | null>(null);
|
} finally {
|
||||||
// Create Gym modal state
|
setLoading(false);
|
||||||
const [showCreateGym, setShowCreateGym] = useState(false);
|
}
|
||||||
const [gymName, setGymName] = useState("");
|
};
|
||||||
const [gymLocation, setGymLocation] = useState("");
|
|
||||||
const [creatingGym, setCreatingGym] = useState(false);
|
|
||||||
|
|
||||||
const fetchBackups = async () => {
|
useEffect(() => {
|
||||||
try {
|
fetchBackups();
|
||||||
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 () => {
|
const handleCreateBackup = async () => {
|
||||||
setGymsLoading(true);
|
setCreatingBackup(true);
|
||||||
setGymMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/api/gyms");
|
await axios.post("/api/admin/backups");
|
||||||
setGyms(Array.isArray(res.data) ? res.data : []);
|
await fetchBackups();
|
||||||
} catch (error) {
|
setMessage({ type: 'success', text: 'Backup created successfully' });
|
||||||
console.error("Failed to fetch gyms:", error);
|
} catch (error) {
|
||||||
setGymMessage({ type: "error", text: "Failed to load gyms" });
|
console.error("Failed to create backup:", error);
|
||||||
} finally {
|
setMessage({ type: 'error', text: 'Failed to create backup' });
|
||||||
setGymsLoading(false);
|
} finally {
|
||||||
}
|
setCreatingBackup(false);
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleRestore = async (filename: string) => {
|
||||||
fetchBackups();
|
if (!window.confirm(`Are you sure you want to restore from ${filename}? This will overwrite the current database.`)) {
|
||||||
fetchGyms();
|
return;
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
const handleCreateBackup = async () => {
|
setRestoring(filename);
|
||||||
setCreatingBackup(true);
|
setMessage(null);
|
||||||
setMessage(null);
|
try {
|
||||||
try {
|
await axios.post("/api/admin/backups/restore", { filename });
|
||||||
await axios.post("/api/admin/backups");
|
setMessage({ type: 'success', text: 'Database restored successfully' });
|
||||||
await fetchBackups();
|
// Optional: Refresh page or force re-login if session is invalidated
|
||||||
setMessage({ type: "success", text: "Backup created successfully" });
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error("Failed to restore backup:", error);
|
||||||
console.error("Failed to create backup:", error);
|
setMessage({ type: 'error', text: 'Failed to restore backup' });
|
||||||
setMessage({ type: "error", text: "Failed to create backup" });
|
} finally {
|
||||||
} finally {
|
setRestoring(null);
|
||||||
setCreatingBackup(false);
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestore = async (filename: string) => {
|
const formatSize = (bytes: number) => {
|
||||||
if (
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
!window.confirm(
|
let size = bytes;
|
||||||
`Are you sure you want to restore from ${filename}? This will overwrite the current database.`,
|
let unitIndex = 0;
|
||||||
)
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
) {
|
size /= 1024;
|
||||||
return;
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||||
|
};
|
||||||
|
|
||||||
setRestoring(filename);
|
const formatDate = (dateString: string) => {
|
||||||
setMessage(null);
|
return new Date(dateString).toLocaleString();
|
||||||
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) => {
|
return (
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
<div className="space-y-8 p-8">
|
||||||
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>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-slate-900">
|
<h2 className="text-3xl font-bold text-slate-900">Settings</h2>
|
||||||
Gym Selection
|
<p className="text-slate-500 mt-2">Manage your application settings and database.</p>
|
||||||
</h3>
|
</div>
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
Select your gym or proceed without a gym
|
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
|
||||||
</p>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
<div className="flex items-center gap-3">
|
||||||
{user ? (
|
<div className="p-2 bg-blue-50 rounded-lg">
|
||||||
<>
|
<Database className="w-6 h-6 text-blue-600" />
|
||||||
Current role:{" "}
|
</div>
|
||||||
<span className="font-medium">
|
<div>
|
||||||
{String(user.publicMetadata?.role ?? "unknown")}
|
<h3 className="text-xl font-bold text-slate-900">Database Management</h3>
|
||||||
</span>
|
<p className="text-sm text-slate-500">Create backups and restore your database</p>
|
||||||
{" • "}
|
</div>
|
||||||
Gym ID:{" "}
|
</div>
|
||||||
<span className="font-medium">
|
<Button
|
||||||
{String(user.publicMetadata?.gymId ?? "none")}
|
onClick={handleCreateBackup}
|
||||||
</span>
|
disabled={creatingBackup}
|
||||||
</>
|
className="flex items-center gap-2"
|
||||||
) : (
|
>
|
||||||
"Loading user metadata..."
|
{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>
|
||||||
)}
|
)}
|
||||||
</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="border rounded-lg overflow-hidden">
|
||||||
<div
|
<table className="w-full text-left text-sm">
|
||||||
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"}`}
|
<thead className="bg-slate-50 border-b">
|
||||||
>
|
<tr>
|
||||||
{gymMessage.type === "success" ? (
|
<th className="px-6 py-4 font-semibold text-slate-900">Filename</th>
|
||||||
<Check className="w-5 h-5" />
|
<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>
|
||||||
<AlertTriangle className="w-5 h-5" />
|
<th className="px-6 py-4 font-semibold text-slate-900 text-right">Actions</th>
|
||||||
)}
|
</tr>
|
||||||
{gymMessage.text}
|
</thead>
|
||||||
</div>
|
<tbody className="divide-y">
|
||||||
)}
|
{loading ? (
|
||||||
{showCreateGym && (
|
<tr>
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<td colSpan={4} className="px-6 py-8 text-center text-slate-500">
|
||||||
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
|
Loading backups...
|
||||||
<h3 className="text-lg font-semibold mb-4">Create Gym</h3>
|
</td>
|
||||||
<form
|
</tr>
|
||||||
onSubmit={async (e) => {
|
) : backups.length === 0 ? (
|
||||||
e.preventDefault();
|
<tr>
|
||||||
try {
|
<td colSpan={4} className="px-6 py-8 text-center text-slate-500">
|
||||||
setCreatingGym(true);
|
No backups found
|
||||||
await axios.post("/api/gyms", {
|
</td>
|
||||||
name: gymName.trim(),
|
</tr>
|
||||||
location: gymLocation.trim() || undefined,
|
) : (
|
||||||
});
|
backups.map((backup) => (
|
||||||
setGymMessage({
|
<tr key={backup.name} className="hover:bg-slate-50 transition-colors">
|
||||||
type: "success",
|
<td className="px-6 py-4 font-medium text-slate-900">{backup.name}</td>
|
||||||
text: "Gym created successfully",
|
<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>
|
||||||
setShowCreateGym(false);
|
<td className="px-6 py-4 text-right">
|
||||||
setGymName("");
|
<Button
|
||||||
setGymLocation("");
|
variant="ghost"
|
||||||
fetchGyms();
|
size="sm"
|
||||||
} catch (error) {
|
onClick={() => handleRestore(backup.name)}
|
||||||
console.error("Failed to create gym:", error);
|
disabled={!!restoring}
|
||||||
setGymMessage({
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
type: "error",
|
>
|
||||||
text: "Failed to create gym",
|
{restoring === backup.name ? (
|
||||||
});
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
} finally {
|
) : (
|
||||||
setCreatingGym(false);
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
}
|
)}
|
||||||
}}
|
Restore
|
||||||
>
|
</Button>
|
||||||
<div className="mb-4">
|
</td>
|
||||||
<label className="block text-sm font-medium mb-1">
|
</tr>
|
||||||
Gym Name
|
))
|
||||||
</label>
|
)}
|
||||||
<input
|
</tbody>
|
||||||
type="text"
|
</table>
|
||||||
value={gymName}
|
|
||||||
onChange={(e) => setGymName(e.target.value)}
|
|
||||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
);
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,12 +16,13 @@ function getTimeAgo(date: Date): string {
|
|||||||
const diffHours = Math.floor(diffMins / 60);
|
const diffHours = Math.floor(diffMins / 60);
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
if (diffMins < 1) return "just now";
|
if (diffMins < 1) return 'just now';
|
||||||
if (diffMins < 60) return `${diffMins}m ago`;
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
if (diffHours < 24) return `${diffHours}h ago`;
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
return `${diffDays}d ago`;
|
return `${diffDays}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -29,8 +30,6 @@ interface User {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
gymId?: string;
|
|
||||||
gymName?: string | null;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isCheckedIn?: boolean;
|
isCheckedIn?: boolean;
|
||||||
checkInTime?: Date;
|
checkInTime?: Date;
|
||||||
@ -62,31 +61,6 @@ export function UserGrid({
|
|||||||
}: UserGridProps) {
|
}: UserGridProps) {
|
||||||
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
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(
|
const columnDefs: ColDef<User>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@ -121,36 +95,16 @@ export function UserGrid({
|
|||||||
roleColors[params.value as keyof typeof roleColors] ||
|
roleColors[params.value as keyof typeof roleColors] ||
|
||||||
"bg-gray-100 text-gray-800";
|
"bg-gray-100 text-gray-800";
|
||||||
|
|
||||||
const label =
|
const label = params.value === 'superAdmin' ? 'Super Admin' : params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||||
params.value === "superAdmin"
|
|
||||||
? "Super Admin"
|
|
||||||
: params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
minWidth: 120,
|
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",
|
headerName: "Phone",
|
||||||
field: "phone",
|
field: "phone",
|
||||||
@ -165,8 +119,7 @@ export function UserGrid({
|
|||||||
filter: "agTextColumnFilter",
|
filter: "agTextColumnFilter",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cellRenderer: (params: any) => {
|
cellRenderer: (params: any) => {
|
||||||
if (!params.value || params.value === "N/A")
|
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
|
||||||
return <span className="text-gray-400">N/A</span>;
|
|
||||||
|
|
||||||
const membershipColors = {
|
const membershipColors = {
|
||||||
vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
|
||||||
@ -177,13 +130,10 @@ export function UserGrid({
|
|||||||
membershipColors[params.value as keyof typeof membershipColors] ||
|
membershipColors[params.value as keyof typeof membershipColors] ||
|
||||||
"bg-gray-100 text-gray-800";
|
"bg-gray-100 text-gray-800";
|
||||||
|
|
||||||
const label =
|
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||||
params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}>
|
||||||
className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -196,8 +146,7 @@ export function UserGrid({
|
|||||||
filter: "agTextColumnFilter",
|
filter: "agTextColumnFilter",
|
||||||
sortable: true,
|
sortable: true,
|
||||||
cellRenderer: (params: any) => {
|
cellRenderer: (params: any) => {
|
||||||
if (!params.value || params.value === "N/A")
|
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
|
||||||
return <span className="text-gray-400">N/A</span>;
|
|
||||||
|
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
active: "bg-green-100 text-green-800",
|
active: "bg-green-100 text-green-800",
|
||||||
@ -209,13 +158,10 @@ export function UserGrid({
|
|||||||
statusColors[params.value as keyof typeof statusColors] ||
|
statusColors[params.value as keyof typeof statusColors] ||
|
||||||
"bg-gray-100 text-gray-800";
|
"bg-gray-100 text-gray-800";
|
||||||
|
|
||||||
const label =
|
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||||
params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
|
||||||
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -232,10 +178,8 @@ export function UserGrid({
|
|||||||
return <span className="text-gray-400">—</span>;
|
return <span className="text-gray-400">—</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkInTime = params.data.checkInTime
|
const checkInTime = params.data.checkInTime ? new Date(params.data.checkInTime) : null;
|
||||||
? new Date(params.data.checkInTime)
|
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : '';
|
||||||
: null;
|
|
||||||
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
|
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
|
||||||
@ -287,9 +231,7 @@ export function UserGrid({
|
|||||||
rowSelection: "multiple" as const,
|
rowSelection: "multiple" as const,
|
||||||
onSelectionChanged: () => {
|
onSelectionChanged: () => {
|
||||||
const selectedNodes = gridRef.current?.api.getSelectedNodes();
|
const selectedNodes = gridRef.current?.api.getSelectedNodes();
|
||||||
const selectedData =
|
const selectedData = selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) || [];
|
||||||
selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) ||
|
|
||||||
[];
|
|
||||||
setSelectedUsers(selectedData);
|
setSelectedUsers(selectedData);
|
||||||
if (selectedData.length === 1 && onUserSelect) {
|
if (selectedData.length === 1 && onUserSelect) {
|
||||||
onUserSelect(selectedData[0]);
|
onUserSelect(selectedData[0]);
|
||||||
|
|||||||
@ -5,7 +5,6 @@ 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 { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useUser } from "@clerk/nextjs";
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -14,7 +13,6 @@ interface User {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
role: string;
|
role: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
gymId?: string;
|
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isCheckedIn?: boolean;
|
isCheckedIn?: boolean;
|
||||||
checkInTime?: Date;
|
checkInTime?: Date;
|
||||||
@ -31,7 +29,6 @@ interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserManagement() {
|
export function UserManagement() {
|
||||||
const { user } = useUser();
|
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
@ -44,32 +41,8 @@ export function UserManagement() {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
gymId: string;
|
|
||||||
} | null>(null);
|
} | 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(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [filter]);
|
}, [filter]);
|
||||||
@ -77,33 +50,10 @@ export function UserManagement() {
|
|||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const ts = Date.now();
|
const url = filter === "all" ? "/api/users" : `/api/users?role=${filter}`;
|
||||||
const url =
|
|
||||||
filter === "all"
|
|
||||||
? `/api/users?ts=${ts}`
|
|
||||||
: `/api/users?role=${filter}&ts=${ts}`;
|
|
||||||
|
|
||||||
console.log("UserManagement.fetchUsers: fetching URL", url);
|
const response = await fetch(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();
|
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 || []);
|
setUsers(data.users || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch users:", error);
|
console.error("Failed to fetch users:", error);
|
||||||
@ -124,7 +74,6 @@ export function UserManagement() {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
phone: user.phone || "",
|
phone: user.phone || "",
|
||||||
gymId: user.gymId || "",
|
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -201,92 +150,24 @@ export function UserManagement() {
|
|||||||
try {
|
try {
|
||||||
if (selectedUser) {
|
if (selectedUser) {
|
||||||
// Update existing user
|
// 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", {
|
const response = await fetch("/api/users", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ id: selectedUser.id, ...editForm }),
|
||||||
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) {
|
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);
|
setIsEditing(false);
|
||||||
setEditForm(null);
|
setEditForm(null);
|
||||||
// Still re-fetch from server to ensure consistency
|
|
||||||
console.log(
|
|
||||||
"UserManagement.handleSaveEdit: re-fetching users after successful edit",
|
|
||||||
);
|
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} else {
|
} else {
|
||||||
const errText = await response.text().catch(() => "");
|
|
||||||
console.error("UserManagement.handleSaveEdit: update failed", {
|
|
||||||
status: response.status,
|
|
||||||
body: errText,
|
|
||||||
});
|
|
||||||
alert("Error updating user");
|
alert("Error updating user");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create (Invite) new user
|
// Create (Invite) new user
|
||||||
const response = await fetch("/api/invitations", {
|
const response = await fetch("/api/users", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(editForm),
|
||||||
inviteeEmail: editForm.email,
|
|
||||||
roleAssigned: editForm.role,
|
|
||||||
gymId: editForm.gymId || undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@ -351,7 +232,6 @@ export function UserManagement() {
|
|||||||
email: "",
|
email: "",
|
||||||
role: "client",
|
role: "client",
|
||||||
phone: "",
|
phone: "",
|
||||||
gymId: String((user?.publicMetadata as any)?.gymId ?? ""),
|
|
||||||
});
|
});
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
@ -428,9 +308,7 @@ export function UserManagement() {
|
|||||||
{isEditing && editForm && (
|
{isEditing && editForm && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
|
||||||
<h3 className="text-lg font-semibold mb-4">
|
<h3 className="text-lg font-semibold mb-4">{selectedUser ? 'Edit User' : 'Invite New User'}</h3>
|
||||||
{selectedUser ? "Edit User" : "Invite New User"}
|
|
||||||
</h3>
|
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -488,8 +366,8 @@ export function UserManagement() {
|
|||||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
{/* Ideally we fetch current user role to filter these.
|
{/* Ideally we fetch current user role to filter these.
|
||||||
For now, we show all but the API will enforce it.
|
For now, we show all but the API will enforce it.
|
||||||
We can add a visual indicator or fetch "me" to filter. */}
|
We can add a visual indicator or fetch "me" to filter. */}
|
||||||
<option value="client">Client</option>
|
<option value="client">Client</option>
|
||||||
<option value="trainer">Trainer</option>
|
<option value="trainer">Trainer</option>
|
||||||
@ -511,26 +389,6 @@ export function UserManagement() {
|
|||||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -546,7 +404,7 @@ export function UserManagement() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -641,8 +499,8 @@ export function UserManagement() {
|
|||||||
<span className="font-medium">Last Visit:</span>{" "}
|
<span className="font-medium">Last Visit:</span>{" "}
|
||||||
{selectedUser.client.lastVisit
|
{selectedUser.client.lastVisit
|
||||||
? new Date(
|
? new Date(
|
||||||
selectedUser.client.lastVisit,
|
selectedUser.client.lastVisit,
|
||||||
).toLocaleDateString()
|
).toLocaleDateString()
|
||||||
: "Never"}
|
: "Never"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -655,7 +513,9 @@ export function UserManagement() {
|
|||||||
<p>
|
<p>
|
||||||
<span className="font-medium">Last Check-In:</span>{" "}
|
<span className="font-medium">Last Check-In:</span>{" "}
|
||||||
{selectedUser.lastCheckInTime
|
{selectedUser.lastCheckInTime
|
||||||
? new Date(selectedUser.lastCheckInTime).toLocaleString()
|
? new Date(
|
||||||
|
selectedUser.lastCheckInTime,
|
||||||
|
).toLocaleString()
|
||||||
: "Never"}
|
: "Never"}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,21 +1,13 @@
|
|||||||
import {
|
import { User as SharedUser, Client, FitnessProfile, Attendance, Recommendation, FitnessGoal } from "@fitai/shared";
|
||||||
User as SharedUser,
|
|
||||||
Client,
|
|
||||||
FitnessProfile,
|
|
||||||
Attendance,
|
|
||||||
Recommendation,
|
|
||||||
FitnessGoal,
|
|
||||||
} from "@fitai/shared";
|
|
||||||
|
|
||||||
// Database Entity Types
|
// Database Entity Types
|
||||||
export interface User extends SharedUser {
|
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;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal };
|
export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal };
|
||||||
|
|
||||||
|
|
||||||
// Database Interface - allows us to swap implementations
|
// Database Interface - allows us to swap implementations
|
||||||
export interface IDatabase {
|
export interface IDatabase {
|
||||||
// Connection management
|
// Connection management
|
||||||
@ -66,10 +58,7 @@ export interface IDatabase {
|
|||||||
|
|
||||||
// Recommendation operations
|
// Recommendation operations
|
||||||
createRecommendation(
|
createRecommendation(
|
||||||
recommendation: Omit<
|
recommendation: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">,
|
||||||
Recommendation,
|
|
||||||
"createdAt" | "approvedAt" | "approvedBy"
|
|
||||||
>,
|
|
||||||
): Promise<Recommendation>;
|
): Promise<Recommendation>;
|
||||||
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
|
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
|
||||||
getAllRecommendations(): Promise<Recommendation[]>;
|
getAllRecommendations(): Promise<Recommendation[]>;
|
||||||
@ -81,22 +70,16 @@ export interface IDatabase {
|
|||||||
|
|
||||||
// Fitness Goals operations
|
// Fitness Goals operations
|
||||||
createFitnessGoal(
|
createFitnessGoal(
|
||||||
goal: Omit<FitnessGoal, "createdAt" | "updatedAt">,
|
goal: Omit<FitnessGoal, "createdAt" | "updatedAt">
|
||||||
): Promise<FitnessGoal>;
|
): Promise<FitnessGoal>;
|
||||||
getFitnessGoalById(id: string): Promise<FitnessGoal | null>;
|
getFitnessGoalById(id: string): Promise<FitnessGoal | null>;
|
||||||
getFitnessGoalsByUserId(
|
getFitnessGoalsByUserId(userId: string, status?: string): Promise<FitnessGoal[]>;
|
||||||
userId: string,
|
|
||||||
status?: string,
|
|
||||||
): Promise<FitnessGoal[]>;
|
|
||||||
updateFitnessGoal(
|
updateFitnessGoal(
|
||||||
id: string,
|
id: string,
|
||||||
updates: Partial<FitnessGoal>,
|
updates: Partial<FitnessGoal>
|
||||||
): Promise<FitnessGoal | null>;
|
): Promise<FitnessGoal | null>;
|
||||||
deleteFitnessGoal(id: string): Promise<boolean>;
|
deleteFitnessGoal(id: string): Promise<boolean>;
|
||||||
updateGoalProgress(
|
updateGoalProgress(id: string, currentValue: number): Promise<FitnessGoal | null>;
|
||||||
id: string,
|
|
||||||
currentValue: number,
|
|
||||||
): Promise<FitnessGoal | null>;
|
|
||||||
completeGoal(id: string): Promise<FitnessGoal | null>;
|
completeGoal(id: string): Promise<FitnessGoal | null>;
|
||||||
|
|
||||||
// Dashboard operations
|
// Dashboard operations
|
||||||
|
|||||||
@ -1,72 +1,62 @@
|
|||||||
import { currentUser } from "@clerk/nextjs/server";
|
import { currentUser } from '@clerk/nextjs/server'
|
||||||
import type { IDatabase, User } from "./database/types";
|
import { IDatabase } from './database/types'
|
||||||
|
|
||||||
export async function ensureUserSynced(
|
export async function ensureUserSynced(userId: string, db: IDatabase) {
|
||||||
userId: string,
|
const existingUser = await db.getUserById(userId)
|
||||||
db: IDatabase,
|
if (existingUser) return existingUser
|
||||||
): 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);
|
console.log('User not found in DB by ID, checking Clerk:', userId)
|
||||||
const clerkUser = await currentUser();
|
const clerkUser = await currentUser()
|
||||||
|
|
||||||
if (!clerkUser || clerkUser.id !== userId) {
|
if (!clerkUser || clerkUser.id !== userId) {
|
||||||
// If we can't get the user from Clerk (e.g. running locally without full auth sync),
|
// 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.
|
// we might want to fail gracefully or throw.
|
||||||
// For now, throw to be safe.
|
// For now, throw to be safe.
|
||||||
throw new Error("Could not fetch Clerk user details");
|
throw new Error('Could not fetch Clerk user details')
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = clerkUser.emailAddresses[0]?.emailAddress;
|
const email = clerkUser.emailAddresses[0]?.emailAddress
|
||||||
if (!email) throw new Error("User has no email");
|
if (!email) throw new Error('User has no email')
|
||||||
|
|
||||||
// Check if user exists by email (e.g. seeded user)
|
// Check if user exists by email (e.g. seeded user)
|
||||||
const existingByEmail = await db.getUserByEmail(email);
|
const existingByEmail = await db.getUserByEmail(email)
|
||||||
if (existingByEmail) {
|
if (existingByEmail) {
|
||||||
console.log("User found by email but ID mismatch. Migrating ID...", {
|
console.log('User found by email but ID mismatch. Migrating ID...', {
|
||||||
oldId: existingByEmail.id,
|
oldId: existingByEmail.id,
|
||||||
newId: userId,
|
newId: userId
|
||||||
});
|
})
|
||||||
|
|
||||||
// Update the ID to match Clerk ID
|
// 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
|
// 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.
|
// 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),
|
// 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.
|
// we should add a method to IDatabase or use a workaround.
|
||||||
// Actually, `SQLiteDatabase` is what we have at runtime.
|
// Actually, `SQLiteDatabase` is what we have at runtime.
|
||||||
// Let's assume we can cast it or add `updateUserId` to the interface.
|
// 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.
|
// 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 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.
|
// 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).
|
// 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.
|
// But since it's a seeded super admin with no data, it's fine.
|
||||||
// However, if they had clients, we'd lose the link.
|
// However, if they had clients, we'd lose the link.
|
||||||
|
|
||||||
// Let's add `migrateUserId(oldId, newId)` to IDatabase.
|
// Let's add `migrateUserId(oldId, newId)` to IDatabase.
|
||||||
await db.migrateUserId(existingByEmail.id, userId);
|
await db.migrateUserId(existingByEmail.id, userId)
|
||||||
return db.getUserById(userId);
|
return db.getUserById(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Creating new user from Clerk data:", userId);
|
console.log('Creating new user from Clerk data:', userId)
|
||||||
const user = await db.createUser({
|
const user = await db.createUser({
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
firstName: clerkUser.firstName || "",
|
firstName: clerkUser.firstName || '',
|
||||||
lastName: clerkUser.lastName || "",
|
lastName: clerkUser.lastName || '',
|
||||||
password: "", // Managed by Clerk
|
password: '', // Managed by Clerk
|
||||||
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
|
||||||
gymId: (clerkUser.publicMetadata.gymId as string | undefined) || undefined,
|
role: (clerkUser.publicMetadata.role as 'admin' | 'client' | 'superAdmin') || 'client'
|
||||||
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,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -18,32 +18,6 @@ export default function OnboardingScreen() {
|
|||||||
const { getToken } = useAuth();
|
const { getToken } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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({
|
const [fitnessProfile, setFitnessProfile] = useState({
|
||||||
height: "",
|
height: "",
|
||||||
weight: "",
|
weight: "",
|
||||||
@ -67,26 +41,6 @@ export default function OnboardingScreen() {
|
|||||||
return;
|
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 = {
|
const fitnessData = {
|
||||||
clientId: user.id,
|
clientId: user.id,
|
||||||
height: parseFloat(fitnessProfile.height),
|
height: parseFloat(fitnessProfile.height),
|
||||||
@ -105,6 +59,10 @@ export default function OnboardingScreen() {
|
|||||||
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
|
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const token = await getToken();
|
||||||
|
if (!token) {
|
||||||
|
throw new Error("Authentication token not available");
|
||||||
|
}
|
||||||
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
|
||||||
router.replace("/(tabs)");
|
router.replace("/(tabs)");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -255,55 +213,6 @@ export default function OnboardingScreen() {
|
|||||||
placeholder="Number of workouts per week"
|
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
|
<TouchableOpacity
|
||||||
style={styles.submitButton}
|
style={styles.submitButton}
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
|
|||||||
@ -1,14 +1,5 @@
|
|||||||
import {
|
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
|
||||||
View,
|
import { useUser, useClerk } from "@clerk/clerk-expo";
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
Image,
|
|
||||||
Alert,
|
|
||||||
ScrollView,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from "react-native";
|
|
||||||
import { useUser, useClerk, useAuth } from "@clerk/clerk-expo";
|
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
@ -30,55 +21,14 @@ export default function ProfileScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const confirmSignOut = () => {
|
const confirmSignOut = () => {
|
||||||
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
|
Alert.alert(
|
||||||
{ text: "Cancel", style: "cancel" },
|
"Sign Out",
|
||||||
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
|
"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 (
|
return (
|
||||||
@ -98,9 +48,7 @@ export default function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.name}>{user?.fullName || "User"}</Text>
|
<Text style={styles.name}>{user?.fullName || "User"}</Text>
|
||||||
<Text style={styles.email}>
|
<Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
|
||||||
{user?.primaryEmailAddress?.emailAddress}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.memberBadge}>
|
<View style={styles.memberBadge}>
|
||||||
<Text style={styles.memberText}>Premium Member</Text>
|
<Text style={styles.memberText}>Premium Member</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -111,144 +59,39 @@ export default function ProfileScreen() {
|
|||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Account</Text>
|
<Text style={styles.sectionTitle}>Account</Text>
|
||||||
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/personal-details')}>
|
||||||
style={styles.infoRow}
|
|
||||||
onPress={() => router.push("/personal-details")}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
<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}
|
style={styles.iconContainer}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="person-outline" size={20} color={theme.colors.primary} />
|
||||||
name="person-outline"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.primary}
|
|
||||||
/>
|
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.infoLabel}>Personal Details</Text>
|
<Text style={styles.infoLabel}>Personal Details</Text>
|
||||||
<Ionicons
|
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||||
name="chevron-forward"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.gray400}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
<TouchableOpacity
|
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/fitness-profile')}>
|
||||||
style={styles.infoRow}
|
|
||||||
onPress={() => router.push("/fitness-profile")}
|
|
||||||
>
|
|
||||||
<LinearGradient
|
<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}
|
style={styles.iconContainer}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="fitness-outline" size={20} color={theme.colors.success} />
|
||||||
name="fitness-outline"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.success}
|
|
||||||
/>
|
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.infoLabel}>Fitness Profile</Text>
|
<Text style={styles.infoLabel}>Fitness Profile</Text>
|
||||||
<Ionicons
|
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||||
name="chevron-forward"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.gray400}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
<TouchableOpacity style={styles.infoRow}>
|
<TouchableOpacity style={styles.infoRow}>
|
||||||
<LinearGradient
|
<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}
|
style={styles.iconContainer}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="notifications-outline" size={20} color={theme.colors.warning} />
|
||||||
name="notifications-outline"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.warning}
|
|
||||||
/>
|
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.infoLabel}>Notifications</Text>
|
<Text style={styles.infoLabel}>Notifications</Text>
|
||||||
<Ionicons
|
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||||
name="chevron-forward"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.gray400}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</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>
|
||||||
|
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
@ -256,43 +99,24 @@ export default function ProfileScreen() {
|
|||||||
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
<View style={[styles.infoCard, theme.shadows.subtle]}>
|
||||||
<TouchableOpacity style={styles.infoRow}>
|
<TouchableOpacity style={styles.infoRow}>
|
||||||
<LinearGradient
|
<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}
|
style={styles.iconContainer}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="help-circle-outline" size={20} color={theme.colors.secondary} />
|
||||||
name="help-circle-outline"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.secondary}
|
|
||||||
/>
|
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.infoLabel}>Help Center</Text>
|
<Text style={styles.infoLabel}>Help Center</Text>
|
||||||
<Ionicons
|
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||||
name="chevron-forward"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.gray400}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.divider} />
|
<View style={styles.divider} />
|
||||||
<TouchableOpacity style={styles.infoRow}>
|
<TouchableOpacity style={styles.infoRow}>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[
|
colors={['rgba(107, 114, 128, 0.1)', 'rgba(107, 114, 128, 0.05)']}
|
||||||
"rgba(107, 114, 128, 0.1)",
|
|
||||||
"rgba(107, 114, 128, 0.05)",
|
|
||||||
]}
|
|
||||||
style={styles.iconContainer}
|
style={styles.iconContainer}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.gray600} />
|
||||||
name="shield-checkmark-outline"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.gray600}
|
|
||||||
/>
|
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.infoLabel}>Privacy & Security</Text>
|
<Text style={styles.infoLabel}>Privacy & Security</Text>
|
||||||
<Ionicons
|
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
|
||||||
name="chevron-forward"
|
|
||||||
size={20}
|
|
||||||
color={theme.colors.gray400}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@ -321,13 +145,13 @@ const styles = StyleSheet.create({
|
|||||||
paddingBottom: 30,
|
paddingBottom: 30,
|
||||||
borderBottomLeftRadius: theme.borderRadius.xl,
|
borderBottomLeftRadius: theme.borderRadius.xl,
|
||||||
borderBottomRightRadius: theme.borderRadius.xl,
|
borderBottomRightRadius: theme.borderRadius.xl,
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
profileCard: {
|
profileCard: {
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
avatarContainer: {
|
avatarContainer: {
|
||||||
position: "relative",
|
position: 'relative',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
@ -335,28 +159,28 @@ const styles = StyleSheet.create({
|
|||||||
height: 100,
|
height: 100,
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
borderWidth: 4,
|
borderWidth: 4,
|
||||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
},
|
},
|
||||||
placeholderAvatar: {
|
placeholderAvatar: {
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
borderRadius: 50,
|
borderRadius: 50,
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
borderWidth: 4,
|
borderWidth: 4,
|
||||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
},
|
},
|
||||||
editBadge: {
|
editBadge: {
|
||||||
position: "absolute",
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
backgroundColor: theme.colors.white,
|
backgroundColor: theme.colors.white,
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
shadowColor: "#000",
|
shadowColor: "#000",
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
@ -364,23 +188,23 @@ const styles = StyleSheet.create({
|
|||||||
elevation: 3,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
fontSize: theme.typography.fontSize["2xl"],
|
fontSize: theme.typography.fontSize['2xl'],
|
||||||
fontWeight: theme.typography.fontWeight.bold,
|
fontWeight: theme.typography.fontWeight.bold,
|
||||||
color: theme.colors.white,
|
color: theme.colors.white,
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
fontSize: theme.typography.fontSize.sm,
|
fontSize: theme.typography.fontSize.sm,
|
||||||
color: "rgba(255, 255, 255, 0.8)",
|
color: 'rgba(255, 255, 255, 0.8)',
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
memberBadge: {
|
memberBadge: {
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: "rgba(255, 255, 255, 0.3)",
|
borderColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
},
|
},
|
||||||
memberText: {
|
memberText: {
|
||||||
color: theme.colors.white,
|
color: theme.colors.white,
|
||||||
@ -410,16 +234,16 @@ const styles = StyleSheet.create({
|
|||||||
borderColor: theme.colors.gray100,
|
borderColor: theme.colors.gray100,
|
||||||
},
|
},
|
||||||
infoRow: {
|
infoRow: {
|
||||||
flexDirection: "row",
|
flexDirection: 'row',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
justifyContent: "center",
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: 'center',
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
},
|
},
|
||||||
infoLabel: {
|
infoLabel: {
|
||||||
@ -437,7 +261,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
version: {
|
version: {
|
||||||
textAlign: "center",
|
textAlign: 'center',
|
||||||
marginTop: 24,
|
marginTop: 24,
|
||||||
color: theme.colors.gray400,
|
color: theme.colors.gray400,
|
||||||
fontSize: theme.typography.fontSize.xs,
|
fontSize: theme.typography.fontSize.xs,
|
||||||
|
|||||||
@ -1,30 +1,29 @@
|
|||||||
export const API_BASE_URL = __DEV__
|
export const API_BASE_URL = __DEV__
|
||||||
? "https://a4db649a0973.ngrok-free.app"
|
? 'https://e0877d294c41.ngrok-free.app'
|
||||||
: "https://your-production-url.com";
|
: 'https://your-production-url.com'
|
||||||
|
|
||||||
export const API_ENDPOINTS = {
|
export const API_ENDPOINTS = {
|
||||||
AUTH: {
|
AUTH: {
|
||||||
LOGIN: "/api/auth/login",
|
LOGIN: '/api/auth/login',
|
||||||
REGISTER: "/api/auth/register",
|
REGISTER: '/api/auth/register',
|
||||||
},
|
},
|
||||||
PROFILE: {
|
PROFILE: {
|
||||||
FITNESS: "/api/profile/fitness",
|
FITNESS: '/api/profile/fitness',
|
||||||
},
|
},
|
||||||
CLIENTS: "/api/clients",
|
CLIENTS: '/api/clients',
|
||||||
USERS: "/api/users",
|
USERS: '/api/users',
|
||||||
GYMS: "/api/gyms",
|
|
||||||
ATTENDANCE: {
|
ATTENDANCE: {
|
||||||
CHECK_IN: "/api/attendance/check-in",
|
CHECK_IN: '/api/attendance/check-in',
|
||||||
CHECK_OUT: "/api/attendance/check-out",
|
CHECK_OUT: '/api/attendance/check-out',
|
||||||
HISTORY: "/api/attendance/history",
|
HISTORY: '/api/attendance/history',
|
||||||
},
|
},
|
||||||
RECOMMENDATIONS: "/api/recommendations",
|
RECOMMENDATIONS: '/api/recommendations',
|
||||||
FITNESS_GOALS: {
|
FITNESS_GOALS: {
|
||||||
LIST: "/api/fitness-goals",
|
LIST: '/api/fitness-goals',
|
||||||
CREATE: "/api/fitness-goals",
|
CREATE: '/api/fitness-goals',
|
||||||
GET: (id: string) => `/api/fitness-goals/${id}`,
|
GET: (id: string) => `/api/fitness-goals/${id}`,
|
||||||
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
|
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
|
||||||
DELETE: (id: string) => `/api/fitness-goals/${id}`,
|
DELETE: (id: string) => `/api/fitness-goals/${id}`,
|
||||||
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
|
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|||||||
@ -1,10 +0,0 @@
|
|||||||
## 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.
|
|
||||||
@ -1,12 +1,15 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from 'better-sqlite3'
|
||||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||||
import * as schema from "./schema";
|
import * as schema from './schema'
|
||||||
|
|
||||||
// Configurable database path with intelligent defaults
|
// Configurable database path with intelligent defaults
|
||||||
const dbPath = "./data/fitai.db";
|
const dbPath = process.env.DATABASE_URL ||
|
||||||
|
(process.env.NODE_ENV === 'production'
|
||||||
|
? './data/fitai.db'
|
||||||
|
: '../../apps/admin/data/fitai.db')
|
||||||
|
|
||||||
const sqlite = new Database(dbPath);
|
const sqlite = new Database(dbPath)
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
export * from "./schema";
|
export * from './schema'
|
||||||
export { eq, and, or, desc, asc, sql } from "drizzle-orm";
|
export { eq, and, or, desc, asc, sql } from 'drizzle-orm'
|
||||||
@ -6,32 +6,10 @@ export const users = sqliteTable("users", {
|
|||||||
firstName: text("first_name").notNull(),
|
firstName: text("first_name").notNull(),
|
||||||
lastName: text("last_name").notNull(),
|
lastName: text("last_name").notNull(),
|
||||||
password: text("password"), // Optional - Clerk handles authentication
|
password: text("password"), // Optional - Clerk handles authentication
|
||||||
role: text("role", {
|
role: text("role", { enum: ["superAdmin", "admin", "trainer", "client"] })
|
||||||
enum: ["superAdmin", "admin", "trainer", "client", "generalUser"],
|
|
||||||
})
|
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("client"),
|
.default("client"),
|
||||||
phone: text("phone"),
|
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" })
|
createdAt: integer("created_at", { mode: "timestamp" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date()),
|
.$defaultFn(() => new Date()),
|
||||||
@ -166,7 +144,7 @@ export const fitnessGoals = sqliteTable("fitness_goals", {
|
|||||||
.references(() => users.id, { onDelete: "cascade" }),
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
fitnessProfileId: text("fitness_profile_id").references(
|
fitnessProfileId: text("fitness_profile_id").references(
|
||||||
() => fitnessProfiles.id,
|
() => fitnessProfiles.id,
|
||||||
{ onDelete: "cascade" },
|
{ onDelete: "cascade" }
|
||||||
),
|
),
|
||||||
|
|
||||||
// Goal details
|
// Goal details
|
||||||
@ -220,24 +198,6 @@ export const fitnessGoals = sqliteTable("fitness_goals", {
|
|||||||
.$defaultFn(() => new Date()),
|
.$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", {
|
export const recommendations = sqliteTable("recommendations", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
@ -283,3 +243,4 @@ export type FitnessGoal = typeof fitnessGoals.$inferSelect;
|
|||||||
export type NewFitnessGoal = typeof fitnessGoals.$inferInsert;
|
export type NewFitnessGoal = typeof fitnessGoals.$inferInsert;
|
||||||
export type Recommendation = typeof recommendations.$inferSelect;
|
export type Recommendation = typeof recommendations.$inferSelect;
|
||||||
export type NewRecommendation = typeof recommendations.$inferInsert;
|
export type NewRecommendation = typeof recommendations.$inferInsert;
|
||||||
|
|
||||||
|
|||||||
@ -1,155 +1,115 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string
|
||||||
email: string;
|
email: string
|
||||||
firstName: string;
|
firstName: string
|
||||||
lastName: string;
|
lastName: string
|
||||||
password?: string;
|
password?: string
|
||||||
phone?: string;
|
phone?: string
|
||||||
role: "superAdmin" | "admin" | "trainer" | "client";
|
role: 'superAdmin' | 'admin' | 'trainer' | 'client'
|
||||||
gymId?: string;
|
imageUrl?: string
|
||||||
imageUrl?: string;
|
createdAt: Date
|
||||||
createdAt: Date;
|
updatedAt: Date
|
||||||
updatedAt: Date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
id: string;
|
id: string
|
||||||
userId: string;
|
userId: string
|
||||||
user?: User;
|
user?: User
|
||||||
membershipType: "basic" | "premium" | "vip";
|
membershipType: 'basic' | 'premium' | 'vip'
|
||||||
membershipStatus: "active" | "inactive" | "suspended" | "expired";
|
membershipStatus: 'active' | 'inactive' | 'suspended' | 'expired'
|
||||||
joinDate: Date;
|
joinDate: Date
|
||||||
lastVisit?: Date;
|
lastVisit?: Date
|
||||||
emergencyContact?: {
|
emergencyContact?: {
|
||||||
name: string;
|
name: string
|
||||||
phone: string;
|
phone: string
|
||||||
relationship: string;
|
relationship: string
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FitnessProfile {
|
export interface FitnessProfile {
|
||||||
id: string;
|
id: string
|
||||||
userId: string;
|
userId: string
|
||||||
height: string;
|
height: string
|
||||||
weight: string;
|
weight: string
|
||||||
age: string;
|
age: string
|
||||||
gender: "male" | "female" | "other";
|
gender: "male" | "female" | "other"
|
||||||
activityLevel:
|
activityLevel: "sedentary" | "lightly_active" | "moderately_active" | "very_active" | "extremely_active"
|
||||||
| "sedentary"
|
fitnessGoals: string[]
|
||||||
| "lightly_active"
|
exerciseHabits: string
|
||||||
| "moderately_active"
|
dietHabits: string
|
||||||
| "very_active"
|
medicalConditions: string
|
||||||
| "extremely_active";
|
allergies?: string
|
||||||
fitnessGoals: string[];
|
injuries?: string
|
||||||
exerciseHabits: string;
|
createdAt: Date
|
||||||
dietHabits: string;
|
updatedAt: Date
|
||||||
medicalConditions: string;
|
|
||||||
allergies?: string;
|
|
||||||
injuries?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Attendance {
|
export interface Attendance {
|
||||||
id: string;
|
id: string
|
||||||
userId: string;
|
userId: string
|
||||||
clientId?: string;
|
clientId?: string
|
||||||
client?: Client;
|
client?: Client
|
||||||
checkInTime: Date;
|
checkInTime: Date
|
||||||
checkOutTime?: Date;
|
checkOutTime?: Date
|
||||||
type: "gym" | "class" | "personal_training";
|
type: 'gym' | 'class' | 'personal_training'
|
||||||
notes?: string;
|
notes?: string
|
||||||
createdAt?: Date;
|
createdAt?: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Recommendation {
|
export interface Recommendation {
|
||||||
id: string;
|
id: string
|
||||||
userId: string;
|
userId: string
|
||||||
fitnessProfileId?: string;
|
fitnessProfileId?: string
|
||||||
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
|
type: "short_term" | "medium_term" | "long_term" | "ai_plan"
|
||||||
recommendationText: string;
|
recommendationText: string
|
||||||
activityPlan?: string;
|
activityPlan?: string
|
||||||
dietPlan?: string;
|
dietPlan?: string
|
||||||
status: "pending" | "approved" | "rejected" | "completed";
|
status: "pending" | "approved" | "rejected" | "completed"
|
||||||
createdAt: Date;
|
createdAt: Date
|
||||||
approvedAt?: Date;
|
approvedAt?: Date
|
||||||
approvedBy?: string;
|
approvedBy?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FitnessGoal {
|
export interface FitnessGoal {
|
||||||
id: string;
|
id: string
|
||||||
userId: string;
|
userId: string
|
||||||
fitnessProfileId?: string;
|
fitnessProfileId?: string
|
||||||
goalType:
|
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom"
|
||||||
| "weight_target"
|
title: string
|
||||||
| "strength_milestone"
|
description?: string
|
||||||
| "endurance_target"
|
targetValue?: number
|
||||||
| "flexibility_goal"
|
currentValue?: number
|
||||||
| "habit_building"
|
unit?: string
|
||||||
| "custom";
|
startDate: Date
|
||||||
title: string;
|
targetDate?: Date
|
||||||
description?: string;
|
completedDate?: Date
|
||||||
targetValue?: number;
|
status: "active" | "completed" | "abandoned" | "paused"
|
||||||
currentValue?: number;
|
progress: number
|
||||||
unit?: string;
|
priority: "low" | "medium" | "high"
|
||||||
startDate: Date;
|
notes?: string
|
||||||
targetDate?: Date;
|
createdAt: Date
|
||||||
completedDate?: Date;
|
updatedAt: Date
|
||||||
status: "active" | "completed" | "abandoned" | "paused";
|
|
||||||
progress: number;
|
|
||||||
priority: "low" | "medium" | "high";
|
|
||||||
notes?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
id: string;
|
id: string
|
||||||
clientId: string;
|
clientId: string
|
||||||
client: Client;
|
client: Client
|
||||||
amount: number;
|
amount: number
|
||||||
currency: string;
|
currency: string
|
||||||
status: "pending" | "completed" | "failed" | "refunded";
|
status: 'pending' | 'completed' | 'failed' | 'refunded'
|
||||||
paymentMethod: "cash" | "card" | "bank_transfer";
|
paymentMethod: 'cash' | 'card' | 'bank_transfer'
|
||||||
dueDate: Date;
|
dueDate: Date
|
||||||
paidAt?: Date;
|
paidAt?: Date
|
||||||
description: string;
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string
|
||||||
userId: string;
|
userId: string
|
||||||
title: string;
|
title: string
|
||||||
message: string;
|
message: string
|
||||||
type: "payment_reminder" | "attendance" | "promotion" | "system";
|
type: 'payment_reminder' | 'attendance' | 'promotion' | 'system'
|
||||||
read: boolean;
|
read: boolean
|
||||||
createdAt: Date;
|
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;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user