working on onboarding flow

This commit is contained in:
echo 2025-12-13 06:26:23 +01:00
parent 868eaa5e3d
commit d3a36b6103
19 changed files with 2062 additions and 393 deletions

Binary file not shown.

View File

@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
/**
* GET /api/admin/clients
*
* Admin:
* - Lists clients scoped to the admin's gym (requires admin.gymId).
*
* SuperAdmin:
* - Optional query param ?gymId=<id> to filter clients by a specific gym.
* - If no gymId provided, returns all clients across all gyms.
*
* Response: Array of client users with minimal fields for listing, including membership data.
*/
export async function GET(req: Request) {
try {
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const db = await getDatabase();
const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 });
}
const url = new URL(req.url);
const requestedGymId = url.searchParams.get("gymId");
// Admins must have a gymId; scope to their gym
let targetGymId: string | null = null;
if (user.role === "admin") {
if (!user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
}
targetGymId = user.gymId;
} else if (user.role === "superAdmin") {
targetGymId = requestedGymId;
}
// Fetch users and clients
const allUsers = await db.getAllUsers();
const usersById = new Map(allUsers.map((u) => [u.id, u]));
const allClients = await db.getAllClients();
// Scope clients by gym when provided
const scopedClients = targetGymId
? allClients.filter((c) => {
const u = usersById.get(c.userId);
return u?.gymId === targetGymId;
})
: allClients;
// Compose payload merging user and client info
const payload = scopedClients.map((c) => {
const u = usersById.get(c.userId);
return {
id: c.id,
userId: c.userId,
email: u?.email ?? null,
firstName: u?.firstName ?? null,
lastName: u?.lastName ?? null,
gymId: u?.gymId ?? null,
membershipType: c.membershipType,
membershipStatus: c.membershipStatus,
joinDate: c.joinDate,
lastVisit: c.lastVisit ?? null,
emergencyContact: c.emergencyContact ?? null,
createdAt: u?.createdAt ?? null,
updatedAt: u?.updatedAt ?? null,
};
});
return NextResponse.json(payload);
} catch (error) {
console.error("GET /api/admin/clients error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}

View File

@ -0,0 +1,166 @@
import { NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
/**
* POST /api/admin/set-user-metadata
*
* Sets Clerk publicMetadata.role and publicMetadata.gymId for a target user.
*
* Authorization:
* - Caller must be an admin or superAdmin (based on their Clerk publicMetadata.role).
*
* Request body:
* {
* "targetUserId": string, // Clerk user ID of the target
* "role": "superAdmin" | "admin" | "trainer" | "client" | "generalUser", // optional
* "gymId": string | null // optional; null clears gym assignment
* }
*
* Behavior:
* - If "role" is provided, update the target user's publicMetadata.role.
* - If "gymId" is provided (including null), update publicMetadata.gymId.
* - Validates inputs and permissions.
*
* Response:
* - 200 with updated minimal user data
* - 400/401/403/404/500 on errors
*/
export async function POST(req: Request) {
try {
// Authenticate the requester
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const client = await clerkClient();
// Fetch requester from Clerk to verify permissions
const requester = await client.users.getUser(userId);
const requesterRole =
(requester.publicMetadata?.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser") ?? "client";
// Only admin or superAdmin can set metadata
if (requesterRole !== "admin" && requesterRole !== "superAdmin") {
return NextResponse.json(
{ error: "Forbidden: admin or superAdmin access required" },
{ status: 403 }
);
}
// Parse body
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return NextResponse.json(
{ error: "Invalid JSON body" },
{ status: 400 }
);
}
const targetUserId = typeof body.targetUserId === "string" ? body.targetUserId : null;
const role = body.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser"
| undefined;
const gymId =
body.gymId === null
? null
: typeof body.gymId === "string"
? body.gymId
: undefined;
if (!targetUserId) {
return NextResponse.json(
{ error: "Invalid or missing targetUserId" },
{ status: 400 }
);
}
// Validate role if provided
const allowedRoles = ["superAdmin", "admin", "trainer", "client", "generalUser"] as const;
if (role && !allowedRoles.includes(role)) {
return NextResponse.json(
{ error: "Invalid role. Must be one of superAdmin, admin, trainer, client, generalUser" },
{ status: 400 }
);
}
// Prevent non-superAdmin from assigning superAdmin
if (role === "superAdmin" && requesterRole !== "superAdmin") {
return NextResponse.json(
{ error: "Only superAdmin can assign superAdmin role" },
{ status: 403 }
);
}
// Fetch target user to ensure they exist
let targetUser;
try {
targetUser = await client.users.getUser(targetUserId);
} catch {
return NextResponse.json({ error: "Target user not found" }, { status: 404 });
}
// Construct new metadata by merging with existing
const newPublicMetadata: Record<string, unknown> = {
...(targetUser.publicMetadata || {}),
};
if (role !== undefined) {
newPublicMetadata.role = role;
}
if (gymId !== undefined) {
newPublicMetadata.gymId = gymId;
}
// Ensure at least one field to update
if (role === undefined && gymId === undefined) {
return NextResponse.json(
{ error: "Provide at least one of 'role' or 'gymId' to update" },
{ status: 400 }
);
}
// Perform update on Clerk
const updatedUser = await client.users.updateUser(targetUserId, {
publicMetadata: newPublicMetadata,
});
// Construct response payload
const primaryEmail =
updatedUser.emailAddresses?.find(
(e) => e.id === updatedUser.primaryEmailAddressId
)?.emailAddress || updatedUser.emailAddresses?.[0]?.emailAddress || null;
return NextResponse.json(
{
success: true,
message: "User metadata updated",
user: {
id: updatedUser.id,
email: primaryEmail,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
role: updatedUser.publicMetadata?.role ?? null,
gymId: updatedUser.publicMetadata?.gymId ?? null,
},
},
{ status: 200 }
);
} catch (error: any) {
const message =
error?.errors?.[0]?.message ||
error?.message ||
"Internal server error";
console.error("Error setting user metadata:", error);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@ -1,25 +1,59 @@
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import { getDatabase } from '@/lib/database'
import { ensureUserSynced } from '@/lib/sync-user'
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
export async function GET() {
try {
const { userId } = await auth()
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
export async function GET(req: Request) {
try {
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const db = await getDatabase()
const user = await ensureUserSynced(userId, db)
const db = await getDatabase();
const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
return new NextResponse('Forbidden', { status: 403 })
}
const stats = await db.getDashboardStats()
return NextResponse.json(stats)
} catch (error) {
console.error('Dashboard stats error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 });
}
if (user.role === "admin" && !user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
}
const url = new URL(req.url);
const searchParams = url.searchParams;
let targetGymId: string | null = null;
if (user.role === "admin") {
targetGymId = user.gymId ?? null;
} else if (user.role === "superAdmin") {
targetGymId = searchParams.get("gymId");
}
const allUsers = await db.getAllUsers();
const allClients = await db.getAllClients();
const usersById = new Map(allUsers.map((u) => [u.id, u]));
const filteredUsers = targetGymId
? allUsers.filter((u) => u.gymId === targetGymId)
: allUsers;
const filteredClients = targetGymId
? allClients.filter((c) => {
const u = usersById.get(c.userId);
return u?.gymId === targetGymId;
})
: allClients;
const stats = {
totalUsers: filteredUsers.length,
activeClients: filteredClients.filter(
(c) => c.membershipStatus === "active",
).length,
totalRevenue: 0,
revenueGrowth: 0,
};
return NextResponse.json(stats);
} catch (error) {
console.error("Dashboard stats error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}

View File

@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { getDatabase } from "@/lib/database";
import { ensureUserSynced } from "@/lib/sync-user";
/**
* GET /api/admin/trainers
*
* Admin:
* - Lists trainers scoped to the admin's gym (requires admin.gymId).
*
* SuperAdmin:
* - Optional query param ?gymId=<id> to filter trainers by a specific gym.
* - If no gymId provided, returns all trainers across all gyms.
*
* Response: Array of trainer users with minimal fields for listing
*/
export async function GET(req: Request) {
try {
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
}
const db = await getDatabase();
const user = await ensureUserSynced(userId, db);
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
return new NextResponse("Forbidden", { status: 403 });
}
const url = new URL(req.url);
const requestedGymId = url.searchParams.get("gymId");
// Admins must have a gymId; scope to their gym
let targetGymId: string | null = null;
if (user.role === "admin") {
if (!user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
}
targetGymId = user.gymId;
} 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 });
}
}

View File

@ -0,0 +1,174 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user";
async function ensureGymsTable() {
await db.run(sql`
CREATE TABLE IF NOT EXISTS gyms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location TEXT,
status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active',
admin_user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
// GET /api/gyms
// Lists active gyms for selection (grid)
export async function GET() {
try {
await ensureGymsTable();
const rows = await db.all(
sql`SELECT * FROM gyms WHERE status = 'active' ORDER BY created_at DESC`,
);
return NextResponse.json(rows);
} catch (error) {
console.error("GET /gyms error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
// POST /api/gyms
// Create a gym. Allowed roles: superAdmin, admin.
// - admin: can only create gyms for themselves (adminUserId = current user)
// - superAdmin: can create for self or specify adminUserId
export async function POST(req: Request) {
try {
await ensureGymsTable();
const { userId } = await auth();
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
// Ensure our local DB has the user synced (role, etc.)
const currentUser = await ensureUserSynced(userId, {
// minimal facade for ensureUserSynced to work: it expects an object implementing part of IDatabase
getUserById: async (id: string) => {
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
updateUser: async (id: string, updates: any) => {
await db
.update(usersTable)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(usersTable.id, id))
.run();
const row = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.get();
return row
? {
id: row.id,
email: row.email,
firstName: row.firstName,
lastName: row.lastName,
password: row.password ?? "",
phone: row.phone ?? undefined,
role: row.role,
imageUrl: undefined,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}
: null;
},
} as any);
if (
!currentUser ||
(currentUser.role !== "admin" && currentUser.role !== "superAdmin")
) {
return new NextResponse("Forbidden", { status: 403 });
}
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object") {
return new NextResponse("Invalid JSON body", { status: 400 });
}
const name = String(body.name ?? "").trim();
const location = body.location ? String(body.location).trim() : null;
let adminUserId: string | null = body.adminUserId
? String(body.adminUserId)
: null;
if (!name) {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
// Enforce admin ownership rules
if (currentUser.role === "admin") {
adminUserId = currentUser.id;
} else if (currentUser.role === "superAdmin") {
adminUserId = adminUserId || currentUser.id;
}
// Basic check that adminUserId exists and is an admin or superAdmin
const adminRow = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, adminUserId!))
.get();
if (
!adminRow ||
(adminRow.role !== "admin" && adminRow.role !== "superAdmin")
) {
return NextResponse.json(
{ error: "adminUserId must reference an admin or superAdmin" },
{ status: 400 },
);
}
const id = generateId();
const nowTs = Date.now();
await db.run(
sql`INSERT INTO gyms (id, name, location, status, admin_user_id, created_at, updated_at)
VALUES (${id}, ${name}, ${location ?? null}, 'active', ${adminUserId!}, ${nowTs}, ${nowTs})`,
);
// Assign the admin to this gym immediately after creation
await db.run(
sql`UPDATE users SET gym_id = ${id}, updated_at = ${nowTs} WHERE id = ${adminUserId!}`,
);
const created = await db.get(sql`SELECT * FROM gyms WHERE id = ${id}`);
return NextResponse.json(created, { status: 201 });
} catch (error) {
console.error("POST /gyms error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
function generateId(): string {
// Simple URL-safe id generator
return (
Math.random().toString(36).slice(2, 10) +
Math.random().toString(36).slice(2, 10)
);
}

View File

@ -0,0 +1,148 @@
import { NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
/**
* POST /api/invitations
*
* Create a Clerk-managed invitation and store role/gym context in publicMetadata.
* This endpoint does not implement invitation acceptance; Clerks 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 });
}
}

View File

@ -0,0 +1,55 @@
import { NextResponse } from 'next/server'
import { auth } from '@clerk/nextjs/server'
import { db, users as usersTable, gyms as gymsTable, eq } 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()
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)
// Ensure user exists
const user = await db.select().from(usersTable).where(eq(usersTable.id, userId)).get()
if (!user) return new NextResponse('User not found', { status: 404 })
// Validate gym when provided
if (gymId) {
const gym = await db.select().from(gymsTable).where(eq(gymsTable.id, gymId)).get()
if (!gym) {
return NextResponse.json({ error: 'Gym not found' }, { status: 404 })
}
if (gym.status !== 'active') {
return NextResponse.json({ error: 'Gym is not active' }, { status: 400 })
}
}
// Update user's gym selection
await db
.update(usersTable)
.set({
gymId: gymId ?? null,
updatedAt: new Date(),
})
.where(eq(usersTable.id, userId))
.run()
const updated = await db.select().from(usersTable).where(eq(usersTable.id, userId)).get()
return NextResponse.json(updated)
} catch (error) {
console.error('PATCH /users/gym error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
}
}

View File

@ -45,14 +45,14 @@ export async function GET(request: NextRequest) {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
checkInsThisWeek = attendanceHistory.filter(
a => new Date(a.checkInTime) >= weekAgo
(a) => new Date(a.checkInTime) >= weekAgo,
).length;
// Calculate check-ins in last 30 days
const monthAgo = new Date();
monthAgo.setDate(monthAgo.getDate() - 30);
checkInsThisMonth = attendanceHistory.filter(
a => new Date(a.checkInTime) >= monthAgo
(a) => new Date(a.checkInTime) >= monthAgo,
).length;
}
@ -63,7 +63,7 @@ export async function GET(request: NextRequest) {
checkInTime,
lastCheckInTime,
checkInsThisWeek,
checkInsThisMonth
checkInsThisMonth,
};
}),
);
@ -88,12 +88,12 @@ export async function POST(request: NextRequest) {
const db = await getDatabase();
// Get current user to check role
// Note: In a real app, we'd map Clerk ID to our DB ID.
// Note: In a real app, we'd map Clerk ID to our DB ID.
// For now, we'll assume we can find the user by some means or trust the Clerk metadata if we synced it.
// Since we don't have Clerk ID in our local DB users table yet (we only have our own ID),
// we might need to rely on the user being synced.
// Let's assume the user calling this API is already in our DB.
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
// OR we'll assume the first user is Super Admin if no users exist?
// Actually, we should look up the user by email if we can't by ID, or add a clerkId column.
// For this step, let's assume we can get the user.
@ -105,7 +105,10 @@ export async function POST(request: NextRequest) {
const currentUser = await db.getUserById(clerkUserId);
if (!currentUser) {
return NextResponse.json({ error: "Current user not found in database" }, { status: 403 });
return NextResponse.json(
{ error: "Current user not found in database" },
{ status: 403 },
);
}
const body = await request.json();
@ -123,14 +126,14 @@ export async function POST(request: NextRequest) {
superAdmin: ["admin", "trainer", "client"],
admin: ["trainer", "client"],
trainer: ["client"],
client: []
client: [],
};
const userRole = currentUser.role as keyof typeof allowed;
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
return NextResponse.json(
{ error: `You are not authorized to create a ${role}` },
{ status: 403 }
{ status: 403 },
);
}
@ -152,20 +155,24 @@ export async function POST(request: NextRequest) {
publicMetadata: {
role,
},
ignoreExisting: true // Don't fail if invite exists
ignoreExisting: true, // Don't fail if invite exists
});
} catch (clerkError: any) {
console.error("Clerk invitation error:", clerkError);
// If user already exists in Clerk, we might want to handle it.
// But for now, let's proceed to create local record if invite sent or if they exist.
if (clerkError.errors?.[0]?.code === 'form_identifier_exists') {
if (clerkError.errors?.[0]?.code === "form_identifier_exists") {
return NextResponse.json(
{ error: "User already exists in Clerk system" },
{ status: 409 },
);
}
return NextResponse.json(
{ error: "Failed to send invitation: " + (clerkError.message || "Unknown error") },
{
error:
"Failed to send invitation: " +
(clerkError.message || "Unknown error"),
},
{ status: 500 },
);
}
@ -182,16 +189,19 @@ export async function POST(request: NextRequest) {
});
// If creating a client, create the client record too
if (role === 'client') {
if (role === "client") {
await db.createClient({
userId: newUserId.id,
membershipType: 'basic',
membershipStatus: 'active',
joinDate: new Date()
membershipType: "basic",
membershipStatus: "active",
joinDate: new Date(),
});
}
return NextResponse.json({ userId: newUserId.id, message: "Invitation sent" }, { status: 201 });
return NextResponse.json(
{ userId: newUserId.id, message: "Invitation sent" },
{ status: 201 },
);
} catch (error) {
console.error("Create user error:", error);
return NextResponse.json(
@ -205,7 +215,7 @@ export async function PUT(request: NextRequest) {
try {
const db = await getDatabase();
const body = await request.json();
const { id, email, firstName, lastName, role, phone } = body;
const { id, email, firstName, lastName, role, phone, gymId } = body;
if (!id) {
return NextResponse.json(
@ -214,12 +224,43 @@ export async function PUT(request: NextRequest) {
);
}
// Get existing user
// Authenticate requester
const { userId: requesterId } = await auth();
if (!requesterId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Fetch requester and target user
const requester = await db.getUserById(requesterId);
if (!requester) {
return NextResponse.json(
{ error: "Requester not found" },
{ status: 403 },
);
}
const existingUser = await db.getUserById(id);
if (!existingUser) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Authorization: determine allowed role changes
const requesterRole = requester.role;
const allowedByRole: Record<string, string[]> = {
superAdmin: ["superAdmin", "admin", "trainer", "client", "generalUser"],
admin: ["admin", "trainer", "client", "generalUser"],
trainer: [], // trainers cannot change roles
client: [], // clients cannot change roles
generalUser: [], // general users cannot change roles
};
if (role && !allowedByRole[requesterRole]?.includes(role)) {
return NextResponse.json(
{ error: `Not authorized to assign role '${role}'` },
{ status: 403 },
);
}
// Check if email is being changed and if it's already taken
if (email && email !== existingUser.email) {
const userWithEmail = await db.getUserByEmail(email);
@ -231,14 +272,39 @@ export async function PUT(request: NextRequest) {
}
}
// Update user
// Update Clerk publicMetadata (role/gymId) to propagate via webhook
// Note: Only update metadata when a change is requested
try {
const client = await clerkClient();
const publicMetadata: Record<string, unknown> = {};
if (role) {
publicMetadata.role = role;
}
if (gymId !== undefined) {
publicMetadata.gymId = gymId === null ? null : String(gymId);
}
if (Object.keys(publicMetadata).length > 0) {
await client.users.updateUser(id, { publicMetadata });
}
} 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)
await db.updateUser(id, {
email: email || existingUser.email,
firstName: firstName || existingUser.firstName,
lastName: lastName || existingUser.lastName,
role: role || existingUser.role,
email: email ?? existingUser.email,
firstName: firstName ?? existingUser.firstName,
lastName: lastName ?? existingUser.lastName,
role: role ?? existingUser.role,
phone: phone !== undefined ? phone : existingUser.phone,
});
gymId: gymId !== undefined ? gymId : existingUser.gymId,
} as any);
return NextResponse.json({ success: true });
} catch (error) {

View File

@ -78,15 +78,31 @@ export async function POST(req: Request) {
);
}
// Determine role from metadata or default to 'client'
// Determine role & gym from metadata
const role =
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
(public_metadata?.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser") || "client";
const gymId = (public_metadata?.gymId as string | null) ?? null;
const inviterUserId =
(public_metadata?.inviterUserId as string | undefined) ?? undefined;
const roleAssigned =
(public_metadata?.roleAssigned as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser"
| undefined) ?? role;
// Insert user into database with Clerk's user ID
const now = new Date().toISOString();
const stmt = db.prepare(`
INSERT INTO users (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, first_name, last_name, password, phone, role, gym_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
@ -97,11 +113,57 @@ export async function POST(req: Request) {
"", // Clerk handles authentication
null, // phone
role,
gymId,
now,
now,
);
console.log(`✅ User ${id} created in database`);
// If this is a client invited by a trainer, create trainer-client link
if (roleAssigned === "client" && inviterUserId && gymId) {
const inviterRow = db
.prepare("SELECT role FROM users WHERE id = ?")
.get(inviterUserId) as { role?: string } | undefined;
if (inviterRow?.role === "trainer") {
const linkId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
const linkStmt = db.prepare(`
INSERT INTO trainer_clients (id, trainer_user_id, client_user_id, gym_id, created_at)
VALUES (?, ?, ?, ?, ?)
`);
linkStmt.run(
linkId,
inviterUserId,
id,
gymId,
new Date().toISOString(),
);
}
}
// If this is a trainer without a gymId but has an inviter, inherit inviter's gymId
if (
(roleAssigned === "trainer" || role === "trainer") &&
!gymId &&
inviterUserId
) {
const inviterGymRow = db
.prepare("SELECT gymId FROM users WHERE id = ?")
.get(inviterUserId) as { gymId?: string } | undefined;
if (inviterGymRow?.gym_id) {
const inheritStmt = db.prepare(`
UPDATE users
SET gym_id = ?, updated_at = ?
WHERE id = ?
`);
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
gymId = inviterGymRow.gym_id;
}
}
console.log(
`✅ User ${id} created in database (role=${role}, gymId=${gymId ?? "null"})`,
);
db.close();
break;
}
@ -124,15 +186,21 @@ export async function POST(req: Request) {
);
}
// Determine role from metadata
// Determine role & gym from metadata
const role =
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
(public_metadata?.role as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser") || "client";
const gymId = (public_metadata?.gymId as string | null) ?? null;
// Update user in database
const now = new Date().toISOString();
const stmt = db.prepare(`
UPDATE users
SET email = ?, firstName = ?, lastName = ?, role = ?, updatedAt = ?
SET email = ?, first_name = ?, last_name = ?, role = ?, gym_id = ?, updated_at = ?
WHERE id = ?
`);
@ -141,10 +209,33 @@ export async function POST(req: Request) {
first_name || "",
last_name || "",
role,
gymId,
now,
id,
);
// If user is a trainer and gymId is missing, attempt to inherit from inviter when available
if (
role === "trainer" &&
!gymId &&
evt.data.public_metadata?.inviterUserId
) {
const inviterUserId = String(evt.data.public_metadata.inviterUserId);
const inviterGymRow = db
.prepare("SELECT gym_id FROM users WHERE id = ?")
.get(inviterUserId) as { gym_id?: string } | undefined;
if (inviterGymRow?.gymId) {
const inheritStmt = db.prepare(`
UPDATE users
SET gym_id = ?, updated_at = ?
WHERE id = ?
`);
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
gymId = inviterGymRow.gym_id;
}
}
console.log(`✅ User ${id} updated in database`);
db.close();
break;

View File

@ -2,174 +2,485 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { Database, Download, RefreshCw, AlertTriangle, Check, Loader2 } from "lucide-react";
import {
Database,
Download,
RefreshCw,
AlertTriangle,
Check,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
interface Backup {
name: string;
size: number;
createdAt: string;
name: string;
size: number;
createdAt: string;
}
interface Gym {
id: string;
name: string;
location?: string | null;
status: "active" | "inactive";
adminUserId: string;
}
export default function SettingsPage() {
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creatingBackup, setCreatingBackup] = useState(false);
const [restoring, setRestoring] = useState<string | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const { user } = useUser();
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creatingBackup, setCreatingBackup] = useState(false);
const [restoring, setRestoring] = useState<string | null>(null);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
const fetchBackups = async () => {
try {
const response = await axios.get("/api/admin/backups");
setBackups(response.data);
} catch (error) {
console.error("Failed to fetch backups:", error);
} finally {
setLoading(false);
}
};
// Gym picker state
const [gyms, setGyms] = useState<Gym[]>([]);
const [gymsLoading, setGymsLoading] = useState<boolean>(true);
const [gymMessage, setGymMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Create Gym modal state
const [showCreateGym, setShowCreateGym] = useState(false);
const [gymName, setGymName] = useState("");
const [gymLocation, setGymLocation] = useState("");
const [creatingGym, setCreatingGym] = useState(false);
useEffect(() => {
fetchBackups();
}, []);
const fetchBackups = async () => {
try {
const response = await axios.get("/api/admin/backups");
setBackups(response.data);
} catch (error) {
console.error("Failed to fetch backups:", error);
} finally {
setLoading(false);
}
};
const handleCreateBackup = async () => {
setCreatingBackup(true);
setMessage(null);
try {
await axios.post("/api/admin/backups");
await fetchBackups();
setMessage({ type: 'success', text: 'Backup created successfully' });
} catch (error) {
console.error("Failed to create backup:", error);
setMessage({ type: 'error', text: 'Failed to create backup' });
} finally {
setCreatingBackup(false);
}
};
const fetchGyms = async () => {
setGymsLoading(true);
setGymMessage(null);
try {
const res = await axios.get("/api/gyms");
setGyms(Array.isArray(res.data) ? res.data : []);
} catch (error) {
console.error("Failed to fetch gyms:", error);
setGymMessage({ type: "error", text: "Failed to load gyms" });
} finally {
setGymsLoading(false);
}
};
const handleRestore = async (filename: string) => {
if (!window.confirm(`Are you sure you want to restore from ${filename}? This will overwrite the current database.`)) {
return;
}
useEffect(() => {
fetchBackups();
fetchGyms();
}, []);
setRestoring(filename);
setMessage(null);
try {
await axios.post("/api/admin/backups/restore", { filename });
setMessage({ type: 'success', text: 'Database restored successfully' });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) {
console.error("Failed to restore backup:", error);
setMessage({ type: 'error', text: 'Failed to restore backup' });
} finally {
setRestoring(null);
}
};
const handleCreateBackup = async () => {
setCreatingBackup(true);
setMessage(null);
try {
await axios.post("/api/admin/backups");
await fetchBackups();
setMessage({ type: "success", text: "Backup created successfully" });
} catch (error) {
console.error("Failed to create backup:", error);
setMessage({ type: "error", text: "Failed to create backup" });
} finally {
setCreatingBackup(false);
}
};
const formatSize = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const handleRestore = async (filename: string) => {
if (
!window.confirm(
`Are you sure you want to restore from ${filename}? This will overwrite the current database.`,
)
) {
return;
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
setRestoring(filename);
setMessage(null);
try {
await axios.post("/api/admin/backups/restore", { filename });
setMessage({ type: "success", text: "Database restored successfully" });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) {
console.error("Failed to restore backup:", error);
setMessage({ type: "error", text: "Failed to restore backup" });
} finally {
setRestoring(null);
}
};
return (
<div className="space-y-8 p-8">
const formatSize = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const handleSelectGym = async (gymId: string | null) => {
setGymMessage(null);
try {
// Update current user's gym selection
await axios.patch("/api/users/gym", { gymId });
setGymMessage({
type: "success",
text: gymId ? "Gym selected successfully" : "Proceeding without gym",
});
} catch (error) {
console.error("Failed to set gym:", error);
setGymMessage({ type: "error", text: "Failed to set gym" });
}
};
return (
<div className="space-y-8 p-8">
<div>
<h2 className="text-3xl font-bold text-slate-900">Settings</h2>
<p className="text-slate-500 mt-2">
Manage your application settings and database.
</p>
</div>
{/* Gym Picker */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<Database className="w-6 h-6 text-blue-600" />
</div>
<div>
<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>
<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>
<h3 className="text-xl font-bold text-slate-900">
Gym Selection
</h3>
<p className="text-sm text-slate-500">
Select your gym or proceed without a gym
</p>
<p className="text-xs text-blue-600 mt-1">
{user ? (
<>
Current role:{" "}
<span className="font-medium">
{String(user.publicMetadata?.role ?? "unknown")}
</span>
{" • "}
Gym ID:{" "}
<span className="font-medium">
{String(user.publicMetadata?.gymId ?? "none")}
</span>
</>
) : (
"Loading user metadata..."
)}
<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>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={fetchGyms}
disabled={gymsLoading}
className="flex items-center gap-2"
>
{gymsLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
Refresh Gyms
</Button>
<Button
onClick={() => setShowCreateGym(true)}
className="flex items-center gap-2"
>
Create Gym
</Button>
</div>
</div>
);
{gymMessage && (
<div
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${gymMessage.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}
>
{gymMessage.type === "success" ? (
<Check className="w-5 h-5" />
) : (
<AlertTriangle className="w-5 h-5" />
)}
{gymMessage.text}
</div>
)}
{showCreateGym && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">Create Gym</h3>
<form
onSubmit={async (e) => {
e.preventDefault();
try {
setCreatingGym(true);
await axios.post("/api/gyms", {
name: gymName.trim(),
location: gymLocation.trim() || undefined,
});
setGymMessage({
type: "success",
text: "Gym created successfully",
});
setShowCreateGym(false);
setGymName("");
setGymLocation("");
fetchGyms();
} catch (error) {
console.error("Failed to create gym:", error);
setGymMessage({
type: "error",
text: "Failed to create gym",
});
} finally {
setCreatingGym(false);
}
}}
>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Gym Name
</label>
<input
type="text"
value={gymName}
onChange={(e) => setGymName(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Location (optional)
</label>
<input
type="text"
value={gymLocation}
onChange={(e) => setGymLocation(e.target.value)}
className="w-full border border-gray-300 rounded px-3 py-2"
placeholder="Enter location"
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setShowCreateGym(false);
setGymName("");
setGymLocation("");
}}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
<button
type="submit"
disabled={creatingGym}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-2"
>
{creatingGym ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : null}
Create
</button>
</div>
</form>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="border rounded-lg p-4 flex flex-col justify-between">
<div>
<h4 className="font-semibold text-slate-900">
Proceed without gym
</h4>
<p className="text-sm text-slate-600 mt-1">
You can select a gym later.
</p>
</div>
<Button
variant="outline"
className="mt-4"
onClick={() => handleSelectGym(null)}
>
Proceed without gym
</Button>
</div>
{gymsLoading ? (
<div className="col-span-full flex items-center justify-center p-8 text-slate-500">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Loading gyms...
</div>
) : gyms.length === 0 ? (
<div className="col-span-full p-8 text-center text-slate-500">
No active gyms found.
</div>
) : (
gyms.map((gym) => (
<div
key={gym.id}
className="border rounded-lg p-4 flex flex-col justify-between"
>
<div>
<h4 className="font-semibold text-slate-900">{gym.name}</h4>
<p className="text-sm text-slate-600 mt-1">
{gym.location || "No location provided"}
</p>
</div>
<Button
variant="default"
className="mt-4"
onClick={() => handleSelectGym(gym.id)}
>
Select this gym
</Button>
</div>
))
)}
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 rounded-lg">
<Database className="w-6 h-6 text-blue-600" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-900">
Database Management
</h3>
<p className="text-sm text-slate-500">
Create backups and restore your database
</p>
</div>
</div>
<Button
onClick={handleCreateBackup}
disabled={creatingBackup}
className="flex items-center gap-2"
>
{creatingBackup ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
Create Backup
</Button>
</div>
{message && (
<div
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${
message.type === "success"
? "bg-green-50 text-green-700"
: "bg-red-50 text-red-700"
}`}
>
{message.type === "success" ? (
<Check className="w-5 h-5" />
) : (
<AlertTriangle className="w-5 h-5" />
)}
{message.text}
</div>
)}
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b">
<tr>
<th className="px-6 py-4 font-semibold text-slate-900">
Filename
</th>
<th className="px-6 py-4 font-semibold text-slate-900">Size</th>
<th className="px-6 py-4 font-semibold text-slate-900">
Created At
</th>
<th className="px-6 py-4 font-semibold text-slate-900 text-right">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td
colSpan={4}
className="px-6 py-8 text-center text-slate-500"
>
Loading backups...
</td>
</tr>
) : backups.length === 0 ? (
<tr>
<td
colSpan={4}
className="px-6 py-8 text-center text-slate-500"
>
No backups found
</td>
</tr>
) : (
backups.map((backup) => (
<tr
key={backup.name}
className="hover:bg-slate-50 transition-colors"
>
<td className="px-6 py-4 font-medium text-slate-900">
{backup.name}
</td>
<td className="px-6 py-4 text-slate-600">
{formatSize(backup.size)}
</td>
<td className="px-6 py-4 text-slate-600">
{formatDate(backup.createdAt)}
</td>
<td className="px-6 py-4 text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRestore(backup.name)}
disabled={!!restoring}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{restoring === backup.name ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Restore
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@ -16,13 +16,12 @@ function getTimeAgo(date: Date): string {
const diffHours = Math.floor(diffMins / 60);
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 (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
interface User {
id: string;
email: string;
@ -30,6 +29,7 @@ interface User {
lastName: string;
role: string;
phone?: string;
gymId?: string;
createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
@ -61,6 +61,31 @@ export function UserGrid({
}: UserGridProps) {
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [searchQuery, setSearchQuery] = useState<string>("");
const [gymNames, setGymNames] = useState<Record<string, string>>({});
React.useEffect(() => {
let isMounted = true;
(async () => {
try {
const res = await fetch("/api/gyms");
const data = await res.json();
if (isMounted && Array.isArray(data)) {
const map: Record<string, string> = {};
for (const g of data) {
if (g && g.id) {
map[g.id] = g.name || g.id;
}
}
setGymNames(map);
}
} catch (e) {
// silently fail; we'll show gymId if name not available
}
})();
return () => {
isMounted = false;
};
}, []);
const columnDefs: ColDef<User>[] = useMemo(
() => [
@ -95,16 +120,34 @@ export function UserGrid({
roleColors[params.value as keyof typeof roleColors] ||
"bg-gray-100 text-gray-800";
const label = params.value === 'superAdmin' ? 'Super Admin' : params.value.charAt(0).toUpperCase() + params.value.slice(1);
const label =
params.value === "superAdmin"
? "Super Admin"
: params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
>
{label}
</span>
);
},
minWidth: 120,
},
{
headerName: "Gym",
field: "gymId",
filter: "agTextColumnFilter",
sortable: true,
minWidth: 160,
valueFormatter: (params: any) => {
const gymId = params.value;
if (!gymId) return "None";
return gymNames[gymId] || gymId;
},
},
{
headerName: "Phone",
field: "phone",
@ -119,7 +162,8 @@ export function UserGrid({
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
if (!params.value || params.value === "N/A")
return <span className="text-gray-400">N/A</span>;
const membershipColors = {
vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
@ -130,10 +174,13 @@ export function UserGrid({
membershipColors[params.value as keyof typeof membershipColors] ||
"bg-gray-100 text-gray-800";
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
const label =
params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}>
<span
className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}
>
{label}
</span>
);
@ -146,7 +193,8 @@ export function UserGrid({
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
if (!params.value || params.value === "N/A")
return <span className="text-gray-400">N/A</span>;
const statusColors = {
active: "bg-green-100 text-green-800",
@ -158,10 +206,13 @@ export function UserGrid({
statusColors[params.value as keyof typeof statusColors] ||
"bg-gray-100 text-gray-800";
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
const label =
params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}>
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
>
{label}
</span>
);
@ -178,8 +229,10 @@ export function UserGrid({
return <span className="text-gray-400"></span>;
}
const checkInTime = params.data.checkInTime ? new Date(params.data.checkInTime) : null;
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : '';
const checkInTime = params.data.checkInTime
? new Date(params.data.checkInTime)
: null;
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : "";
return (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
@ -231,7 +284,9 @@ export function UserGrid({
rowSelection: "multiple" as const,
onSelectionChanged: () => {
const selectedNodes = gridRef.current?.api.getSelectedNodes();
const selectedData = selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) || [];
const selectedData =
selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) ||
[];
setSelectedUsers(selectedData);
if (selectedData.length === 1 && onUserSelect) {
onUserSelect(selectedData[0]);

View File

@ -5,6 +5,7 @@ import { UserGrid } from "@/components/users/UserGrid";
// import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
interface User {
id: string;
@ -13,6 +14,7 @@ interface User {
lastName: string;
role: string;
phone?: string;
gymId?: string;
createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
@ -29,6 +31,7 @@ interface User {
}
export function UserManagement() {
const { user } = useUser();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<string>("all");
@ -41,8 +44,32 @@ export function UserManagement() {
email: string;
role: string;
phone: string;
gymId: string;
} | null>(null);
// Active gyms for dropdown
const [gyms, setGyms] = useState<Array<{ id: string; name: string }>>([]);
// Load gyms when modal opens or refreshes
useEffect(() => {
if (isEditing) {
(async () => {
try {
const res = await fetch("/api/gyms");
const data = await res.json();
if (Array.isArray(data)) {
// map down to id and name to avoid extra payload use here
setGyms(data.map((g: any) => ({ id: g.id, name: g.name })));
} else {
setGyms([]);
}
} catch {
setGyms([]);
}
})();
}
}, [isEditing]);
useEffect(() => {
fetchUsers();
}, [filter]);
@ -74,6 +101,7 @@ export function UserManagement() {
email: user.email,
role: user.role,
phone: user.phone || "",
gymId: user.gymId || "",
});
setIsEditing(true);
};
@ -150,10 +178,14 @@ export function UserManagement() {
try {
if (selectedUser) {
// Update existing user
const response = await fetch("/api/users", {
method: "PUT",
const response = await fetch("/api/admin/set-user-metadata", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedUser.id, ...editForm }),
body: JSON.stringify({
targetUserId: selectedUser.id,
role: editForm.role,
gymId: editForm.gymId === "" ? null : editForm.gymId,
}),
});
if (response.ok) {
setIsEditing(false);
@ -164,10 +196,14 @@ export function UserManagement() {
}
} else {
// Create (Invite) new user
const response = await fetch("/api/users", {
const response = await fetch("/api/invitations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
body: JSON.stringify({
inviteeEmail: editForm.email,
roleAssigned: editForm.role,
gymId: editForm.gymId || undefined,
}),
});
if (response.ok) {
@ -232,6 +268,7 @@ export function UserManagement() {
email: "",
role: "client",
phone: "",
gymId: String((user?.publicMetadata as any)?.gymId ?? ""),
});
setSelectedUser(null);
setIsEditing(true);
@ -308,7 +345,9 @@ export function UserManagement() {
{isEditing && editForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<h3 className="text-lg font-semibold mb-4">{selectedUser ? 'Edit User' : 'Invite New User'}</h3>
<h3 className="text-lg font-semibold mb-4">
{selectedUser ? "Edit User" : "Invite New User"}
</h3>
<form
onSubmit={(e) => {
e.preventDefault();
@ -366,8 +405,8 @@ export function UserManagement() {
className="w-full border border-gray-300 rounded px-3 py-2"
required
>
{/* Ideally we fetch current user role to filter these.
For now, we show all but the API will enforce it.
{/* Ideally we fetch current user role to filter these.
For now, we show all but the API will enforce it.
We can add a visual indicator or fetch "me" to filter. */}
<option value="client">Client</option>
<option value="trainer">Trainer</option>
@ -389,6 +428,26 @@ export function UserManagement() {
className="w-full border border-gray-300 rounded px-3 py-2"
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Gym</label>
<select
value={editForm.gymId}
onChange={(e) =>
setEditForm({ ...editForm, gymId: e.target.value })
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="">Proceed without gym</option>
{gyms.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Select an active gym or proceed without a gym.
</p>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
@ -404,7 +463,7 @@ export function UserManagement() {
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{selectedUser ? 'Save Changes' : 'Send Invitation'}
{selectedUser ? "Save Changes" : "Send Invitation"}
</button>
</div>
</form>
@ -499,8 +558,8 @@ export function UserManagement() {
<span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit
? new Date(
selectedUser.client.lastVisit,
).toLocaleDateString()
selectedUser.client.lastVisit,
).toLocaleDateString()
: "Never"}
</p>
</div>
@ -513,9 +572,7 @@ export function UserManagement() {
<p>
<span className="font-medium">Last Check-In:</span>{" "}
{selectedUser.lastCheckInTime
? new Date(
selectedUser.lastCheckInTime,
).toLocaleString()
? new Date(selectedUser.lastCheckInTime).toLocaleString()
: "Never"}
</p>
<p>

View File

@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
View,
Text,
@ -18,6 +18,32 @@ export default function OnboardingScreen() {
const { getToken } = useAuth();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [gyms, setGyms] = useState<
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState<boolean>(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
useEffect(() => {
const loadGyms = async () => {
try {
setGymsLoading(true);
const token = await getToken();
const res = await fetch("/api/gyms", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
if (Array.isArray(data)) {
setGyms(data);
}
} catch (e) {
console.error("Failed to fetch gyms:", e);
} finally {
setGymsLoading(false);
}
};
loadGyms();
}, []);
const [fitnessProfile, setFitnessProfile] = useState({
height: "",
weight: "",
@ -41,6 +67,26 @@ export default function OnboardingScreen() {
return;
}
const token = await getToken();
if (!token) {
throw new Error("Authentication token not available");
}
// If gym was selected or cleared, patch user's gym selection first
// selectedGymId: string gym id, or null to proceed without gym
try {
await fetch("/api/users/gym", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ gymId: selectedGymId }),
});
} catch (e) {
console.warn("Failed to update gym selection:", e);
}
const fitnessData = {
clientId: user.id,
height: parseFloat(fitnessProfile.height),
@ -59,10 +105,6 @@ export default function OnboardingScreen() {
workoutFrequency: parseInt(fitnessProfile.workoutFrequency) || 3,
};
const token = await getToken();
if (!token) {
throw new Error("Authentication token not available");
}
await fitnessProfileApi.createFitnessProfile(fitnessData, token);
router.replace("/(tabs)");
} catch (error) {
@ -213,6 +255,55 @@ export default function OnboardingScreen() {
placeholder="Number of workouts per week"
/>
<Text style={styles.label}>Select a Gym</Text>
{gymsLoading ? (
<ActivityIndicator />
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ marginBottom: 16 }}
>
<View style={{ flexDirection: "row" }}>
<TouchableOpacity
style={[
styles.levelButton,
selectedGymId === null && styles.selectedButton,
]}
onPress={() => setSelectedGymId(null)}
>
<Text
style={[
styles.levelButtonText,
selectedGymId === null && styles.selectedButtonText,
]}
>
Proceed without gym
</Text>
</TouchableOpacity>
{gyms.map((gym) => (
<TouchableOpacity
key={gym.id}
style={[
styles.levelButton,
selectedGymId === gym.id && styles.selectedButton,
]}
onPress={() => setSelectedGymId(gym.id)}
>
<Text
style={[
styles.levelButtonText,
selectedGymId === gym.id && styles.selectedButtonText,
]}
>
{gym.name}
</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
<TouchableOpacity
style={styles.submitButton}
onPress={handleSubmit}

View File

@ -1,5 +1,14 @@
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native";
import { useUser, useClerk } from "@clerk/clerk-expo";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
Alert,
ScrollView,
ActivityIndicator,
} from "react-native";
import { useUser, useClerk, useAuth } from "@clerk/clerk-expo";
import { useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
@ -21,14 +30,55 @@ export default function ProfileScreen() {
};
const confirmSignOut = () => {
Alert.alert(
"Sign Out",
"Are you sure you want to sign out?",
[
{ text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
]
);
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
]);
};
// Gym selection state and handlers
const { getToken } = useAuth();
const [gyms, setGyms] = useState<
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
const loadGyms = async () => {
try {
setGymsLoading(true);
const token = await getToken();
const res = await fetch("/api/gyms", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
setGyms(Array.isArray(data) ? data : []);
} catch (err) {
console.error("Failed to fetch gyms:", err);
} finally {
setGymsLoading(false);
}
};
const handleApplyGym = async () => {
try {
const token = await getToken();
await fetch("/api/users/gym", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ gymId: selectedGymId }),
});
Alert.alert(
"Success",
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
);
} catch (err) {
console.error("Failed to update gym selection:", err);
Alert.alert("Error", "Failed to update gym selection");
}
};
return (
@ -48,7 +98,9 @@ export default function ProfileScreen() {
</View>
</View>
<Text style={styles.name}>{user?.fullName || "User"}</Text>
<Text style={styles.email}>{user?.primaryEmailAddress?.emailAddress}</Text>
<Text style={styles.email}>
{user?.primaryEmailAddress?.emailAddress}
</Text>
<View style={styles.memberBadge}>
<Text style={styles.memberText}>Premium Member</Text>
</View>
@ -59,39 +111,144 @@ export default function ProfileScreen() {
<View style={styles.section}>
<Text style={styles.sectionTitle}>Account</Text>
<View style={[styles.infoCard, theme.shadows.subtle]}>
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/personal-details')}>
<TouchableOpacity
style={styles.infoRow}
onPress={() => router.push("/personal-details")}
>
<LinearGradient
colors={['rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.05)']}
colors={["rgba(59, 130, 246, 0.1)", "rgba(59, 130, 246, 0.05)"]}
style={styles.iconContainer}
>
<Ionicons name="person-outline" size={20} color={theme.colors.primary} />
<Ionicons
name="person-outline"
size={20}
color={theme.colors.primary}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Personal Details</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/fitness-profile')}>
<TouchableOpacity
style={styles.infoRow}
onPress={() => router.push("/fitness-profile")}
>
<LinearGradient
colors={['rgba(16, 185, 129, 0.1)', 'rgba(16, 185, 129, 0.05)']}
colors={["rgba(16, 185, 129, 0.1)", "rgba(16, 185, 129, 0.05)"]}
style={styles.iconContainer}
>
<Ionicons name="fitness-outline" size={20} color={theme.colors.success} />
<Ionicons
name="fitness-outline"
size={20}
color={theme.colors.success}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Fitness Profile</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(245, 158, 11, 0.1)', 'rgba(245, 158, 11, 0.05)']}
colors={["rgba(245, 158, 11, 0.1)", "rgba(245, 158, 11, 0.05)"]}
style={styles.iconContainer}
>
<Ionicons name="notifications-outline" size={20} color={theme.colors.warning} />
<Ionicons
name="notifications-outline"
size={20}
color={theme.colors.warning}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Notifications</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
</View>
{/* Gym Selection */}
<View
style={[styles.infoCard, theme.shadows.subtle, { marginTop: 12 }]}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: 12,
}}
>
<Text style={styles.infoLabel}>Gym</Text>
<TouchableOpacity onPress={loadGyms}>
<Text style={{ color: theme.colors.primary }}>
Refresh Gyms
</Text>
</TouchableOpacity>
</View>
{gymsLoading ? (
<ActivityIndicator style={{ padding: 12 }} />
) : (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{ paddingHorizontal: 12, paddingBottom: 12 }}
>
<View style={{ flexDirection: "row" }}>
<TouchableOpacity
style={[
styles.infoRow,
selectedGymId === null && {
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
]}
onPress={() => setSelectedGymId(null)}
>
<Text style={styles.infoLabel}>Proceed without gym</Text>
</TouchableOpacity>
{gyms.map((gym) => (
<TouchableOpacity
key={gym.id}
style={[
styles.infoRow,
selectedGymId === gym.id && {
backgroundColor: "rgba(59, 130, 246, 0.1)",
},
]}
onPress={() => setSelectedGymId(gym.id)}
>
<Text style={styles.infoLabel}>{gym.name}</Text>
</TouchableOpacity>
))}
</View>
</ScrollView>
)}
<View style={{ padding: 12 }}>
<AnimatedButton
title="Apply Gym Selection"
onPress={handleApplyGym}
variant="primary"
style={{ marginTop: 4 }}
icon={
<Ionicons
name="checkmark-outline"
size={20}
color={theme.colors.white}
/>
}
/>
</View>
</View>
</View>
<View style={styles.section}>
@ -99,24 +256,43 @@ export default function ProfileScreen() {
<View style={[styles.infoCard, theme.shadows.subtle]}>
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(139, 92, 246, 0.1)', 'rgba(139, 92, 246, 0.05)']}
colors={["rgba(139, 92, 246, 0.1)", "rgba(139, 92, 246, 0.05)"]}
style={styles.iconContainer}
>
<Ionicons name="help-circle-outline" size={20} color={theme.colors.secondary} />
<Ionicons
name="help-circle-outline"
size={20}
color={theme.colors.secondary}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Help Center</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
<View style={styles.divider} />
<TouchableOpacity style={styles.infoRow}>
<LinearGradient
colors={['rgba(107, 114, 128, 0.1)', 'rgba(107, 114, 128, 0.05)']}
colors={[
"rgba(107, 114, 128, 0.1)",
"rgba(107, 114, 128, 0.05)",
]}
style={styles.iconContainer}
>
<Ionicons name="shield-checkmark-outline" size={20} color={theme.colors.gray600} />
<Ionicons
name="shield-checkmark-outline"
size={20}
color={theme.colors.gray600}
/>
</LinearGradient>
<Text style={styles.infoLabel}>Privacy & Security</Text>
<Ionicons name="chevron-forward" size={20} color={theme.colors.gray400} />
<Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity>
</View>
</View>
@ -145,13 +321,13 @@ const styles = StyleSheet.create({
paddingBottom: 30,
borderBottomLeftRadius: theme.borderRadius.xl,
borderBottomRightRadius: theme.borderRadius.xl,
alignItems: 'center',
alignItems: "center",
},
profileCard: {
alignItems: 'center',
alignItems: "center",
},
avatarContainer: {
position: 'relative',
position: "relative",
marginBottom: 16,
},
avatar: {
@ -159,28 +335,28 @@ const styles = StyleSheet.create({
height: 100,
borderRadius: 50,
borderWidth: 4,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: "rgba(255, 255, 255, 0.3)",
},
placeholderAvatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: "rgba(255, 255, 255, 0.2)",
justifyContent: "center",
alignItems: "center",
borderWidth: 4,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: "rgba(255, 255, 255, 0.3)",
},
editBadge: {
position: 'absolute',
position: "absolute",
bottom: 0,
right: 0,
backgroundColor: theme.colors.white,
width: 32,
height: 32,
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
@ -188,23 +364,23 @@ const styles = StyleSheet.create({
elevation: 3,
},
name: {
fontSize: theme.typography.fontSize['2xl'],
fontSize: theme.typography.fontSize["2xl"],
fontWeight: theme.typography.fontWeight.bold,
color: theme.colors.white,
marginBottom: 4,
},
email: {
fontSize: theme.typography.fontSize.sm,
color: 'rgba(255, 255, 255, 0.8)',
color: "rgba(255, 255, 255, 0.8)",
marginBottom: 12,
},
memberBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.2)',
backgroundColor: "rgba(255, 255, 255, 0.2)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: "rgba(255, 255, 255, 0.3)",
},
memberText: {
color: theme.colors.white,
@ -234,16 +410,16 @@ const styles = StyleSheet.create({
borderColor: theme.colors.gray100,
},
infoRow: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
padding: 12,
},
iconContainer: {
width: 36,
height: 36,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
marginRight: 12,
},
infoLabel: {
@ -261,7 +437,7 @@ const styles = StyleSheet.create({
marginTop: 8,
},
version: {
textAlign: 'center',
textAlign: "center",
marginTop: 24,
color: theme.colors.gray400,
fontSize: theme.typography.fontSize.xs,

View File

@ -1,29 +1,30 @@
export const API_BASE_URL = __DEV__
? 'https://e0877d294c41.ngrok-free.app'
: 'https://your-production-url.com'
? "https://e0877d294c41.ngrok-free.app"
: "https://your-production-url.com";
export const API_ENDPOINTS = {
AUTH: {
LOGIN: '/api/auth/login',
REGISTER: '/api/auth/register',
LOGIN: "/api/auth/login",
REGISTER: "/api/auth/register",
},
PROFILE: {
FITNESS: '/api/profile/fitness',
FITNESS: "/api/profile/fitness",
},
CLIENTS: '/api/clients',
USERS: '/api/users',
CLIENTS: "/api/clients",
USERS: "/api/users",
GYMS: "/api/gyms",
ATTENDANCE: {
CHECK_IN: '/api/attendance/check-in',
CHECK_OUT: '/api/attendance/check-out',
HISTORY: '/api/attendance/history',
CHECK_IN: "/api/attendance/check-in",
CHECK_OUT: "/api/attendance/check-out",
HISTORY: "/api/attendance/history",
},
RECOMMENDATIONS: '/api/recommendations',
RECOMMENDATIONS: "/api/recommendations",
FITNESS_GOALS: {
LIST: '/api/fitness-goals',
CREATE: '/api/fitness-goals',
LIST: "/api/fitness-goals",
CREATE: "/api/fitness-goals",
GET: (id: string) => `/api/fitness-goals/${id}`,
UPDATE: (id: string) => `/api/fitness-goals/${id}`,
DELETE: (id: string) => `/api/fitness-goals/${id}`,
COMPLETE: (id: string) => `/api/fitness-goals/${id}/complete`,
},
}
};

10
onboarding.md Normal file
View File

@ -0,0 +1,10 @@
## onboarding
on first login user can choose gym or to proceed without gym, gyms are connected to admin account. gyms are presented as a grid with proceed without gym option. user can select gym at a later date in profile screen on the mobile app where we should have select gym option.
admins can add/invite trainers, and trainers can add/invite clients.
admins can see only trainers and clients from their gym as well statistic for their gym.
user invited by admins and trainers are asignt to their gym.
superAdmin can see all data.
we should use clerk invitation functionalyty to invite users.

View File

@ -6,10 +6,32 @@ export const users = sqliteTable("users", {
firstName: text("first_name").notNull(),
lastName: text("last_name").notNull(),
password: text("password"), // Optional - Clerk handles authentication
role: text("role", { enum: ["superAdmin", "admin", "trainer", "client"] })
role: text("role", {
enum: ["superAdmin", "admin", "trainer", "client", "generalUser"],
})
.notNull()
.default("client"),
phone: text("phone"),
// Remove direct foreign key reference to avoid circular dependency; validate at application level
gymId: text("gym_id"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const gyms = sqliteTable("gyms", {
id: text("id").primaryKey(),
name: text("name").notNull(),
location: text("location"),
status: text("status", { enum: ["active", "inactive"] })
.notNull()
.default("active"),
adminUserId: text("admin_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
@ -144,7 +166,7 @@ export const fitnessGoals = sqliteTable("fitness_goals", {
.references(() => users.id, { onDelete: "cascade" }),
fitnessProfileId: text("fitness_profile_id").references(
() => fitnessProfiles.id,
{ onDelete: "cascade" }
{ onDelete: "cascade" },
),
// Goal details
@ -198,6 +220,24 @@ export const fitnessGoals = sqliteTable("fitness_goals", {
.$defaultFn(() => new Date()),
});
// Removed local invitations table; Clerk invitations are the source of truth
export const trainerClients = sqliteTable("trainer_clients", {
id: text("id").primaryKey(),
trainerUserId: text("trainer_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
clientUserId: text("client_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
gymId: text("gym_id")
.notNull()
.references(() => gyms.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
export const recommendations = sqliteTable("recommendations", {
id: text("id").primaryKey(),
userId: text("user_id")
@ -243,4 +283,3 @@ export type FitnessGoal = typeof fitnessGoals.$inferSelect;
export type NewFitnessGoal = typeof fitnessGoals.$inferInsert;
export type Recommendation = typeof recommendations.$inferSelect;
export type NewRecommendation = typeof recommendations.$inferInsert;

View File

@ -1,115 +1,155 @@
export interface User {
id: string
email: string
firstName: string
lastName: string
password?: string
phone?: string
role: 'superAdmin' | 'admin' | 'trainer' | 'client'
imageUrl?: string
createdAt: Date
updatedAt: Date
id: string;
email: string;
firstName: string;
lastName: string;
password?: string;
phone?: string;
role: "superAdmin" | "admin" | "trainer" | "client" | "generalUser";
gymId?: string;
imageUrl?: string;
createdAt: Date;
updatedAt: Date;
}
export interface Client {
id: string
userId: string
user?: User
membershipType: 'basic' | 'premium' | 'vip'
membershipStatus: 'active' | 'inactive' | 'suspended' | 'expired'
joinDate: Date
lastVisit?: Date
id: string;
userId: string;
user?: User;
membershipType: "basic" | "premium" | "vip";
membershipStatus: "active" | "inactive" | "suspended" | "expired";
joinDate: Date;
lastVisit?: Date;
emergencyContact?: {
name: string
phone: string
relationship: string
}
name: string;
phone: string;
relationship: string;
};
}
export interface FitnessProfile {
id: string
userId: string
height: string
weight: string
age: string
gender: "male" | "female" | "other"
activityLevel: "sedentary" | "lightly_active" | "moderately_active" | "very_active" | "extremely_active"
fitnessGoals: string[]
exerciseHabits: string
dietHabits: string
medicalConditions: string
allergies?: string
injuries?: string
createdAt: Date
updatedAt: Date
id: string;
userId: string;
height: string;
weight: string;
age: string;
gender: "male" | "female" | "other";
activityLevel:
| "sedentary"
| "lightly_active"
| "moderately_active"
| "very_active"
| "extremely_active";
fitnessGoals: string[];
exerciseHabits: string;
dietHabits: string;
medicalConditions: string;
allergies?: string;
injuries?: string;
createdAt: Date;
updatedAt: Date;
}
export interface Attendance {
id: string
userId: string
clientId?: string
client?: Client
checkInTime: Date
checkOutTime?: Date
type: 'gym' | 'class' | 'personal_training'
notes?: string
createdAt?: Date
id: string;
userId: string;
clientId?: string;
client?: Client;
checkInTime: Date;
checkOutTime?: Date;
type: "gym" | "class" | "personal_training";
notes?: string;
createdAt?: Date;
}
export interface Recommendation {
id: string
userId: string
fitnessProfileId?: string
type: "short_term" | "medium_term" | "long_term" | "ai_plan"
recommendationText: string
activityPlan?: string
dietPlan?: string
status: "pending" | "approved" | "rejected" | "completed"
createdAt: Date
approvedAt?: Date
approvedBy?: string
id: string;
userId: string;
fitnessProfileId?: string;
type: "short_term" | "medium_term" | "long_term" | "ai_plan";
recommendationText: string;
activityPlan?: string;
dietPlan?: string;
status: "pending" | "approved" | "rejected" | "completed";
createdAt: Date;
approvedAt?: Date;
approvedBy?: string;
}
export interface FitnessGoal {
id: string
userId: string
fitnessProfileId?: string
goalType: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom"
title: string
description?: string
targetValue?: number
currentValue?: number
unit?: string
startDate: Date
targetDate?: Date
completedDate?: Date
status: "active" | "completed" | "abandoned" | "paused"
progress: number
priority: "low" | "medium" | "high"
notes?: string
createdAt: Date
updatedAt: Date
id: string;
userId: string;
fitnessProfileId?: string;
goalType:
| "weight_target"
| "strength_milestone"
| "endurance_target"
| "flexibility_goal"
| "habit_building"
| "custom";
title: string;
description?: string;
targetValue?: number;
currentValue?: number;
unit?: string;
startDate: Date;
targetDate?: Date;
completedDate?: Date;
status: "active" | "completed" | "abandoned" | "paused";
progress: number;
priority: "low" | "medium" | "high";
notes?: string;
createdAt: Date;
updatedAt: Date;
}
export interface Payment {
id: string
clientId: string
client: Client
amount: number
currency: string
status: 'pending' | 'completed' | 'failed' | 'refunded'
paymentMethod: 'cash' | 'card' | 'bank_transfer'
dueDate: Date
paidAt?: Date
description: string
id: string;
clientId: string;
client: Client;
amount: number;
currency: string;
status: "pending" | "completed" | "failed" | "refunded";
paymentMethod: "cash" | "card" | "bank_transfer";
dueDate: Date;
paidAt?: Date;
description: string;
}
export interface Notification {
id: string
userId: string
title: string
message: string
type: 'payment_reminder' | 'attendance' | 'promotion' | 'system'
read: boolean
createdAt: Date
}
id: string;
userId: string;
title: string;
message: string;
type: "payment_reminder" | "attendance" | "promotion" | "system";
read: boolean;
createdAt: Date;
}
export interface Gym {
id: string;
name: string;
location?: string;
status: "active" | "inactive";
adminUserId: string;
}
export interface Invitation {
id: string;
inviterUserId: string;
inviteeEmail: string;
roleAssigned: "trainer" | "client" | "admin";
gymId: string;
token: string;
status: "sent" | "accepted" | "expired";
expiresAt: Date;
createdAt: Date;
}
export interface TrainerClient {
id: string;
trainerUserId: string;
clientUserId: string;
gymId: string;
createdAt: Date;
}