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 { 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() { export async function GET(req: Request) {
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 });
}
if (user.role === "admin" && !user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
} }
const stats = await db.getDashboardStats() const url = new URL(req.url);
const searchParams = url.searchParams;
return NextResponse.json(stats) 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) { } catch (error) {
console.error('Dashboard stats error:', error) console.error("Dashboard stats error:", error);
return new NextResponse('Internal Server Error', { status: 500 }) 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(); 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;
} }
@ -63,7 +63,7 @@ export async function GET(request: NextRequest) {
checkInTime, checkInTime,
lastCheckInTime, lastCheckInTime,
checkInsThisWeek, checkInsThisWeek,
checkInsThisMonth checkInsThisMonth,
}; };
}), }),
); );
@ -105,7 +105,10 @@ 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({ 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(); const body = await request.json();
@ -123,14 +126,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 },
); );
} }
@ -152,20 +155,24 @@ 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 },
); );
} }
@ -182,16 +189,19 @@ 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({ userId: newUserId.id, message: "Invitation sent" }, { status: 201 }); return NextResponse.json(
{ 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(
@ -205,7 +215,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 } = body; const { id, email, firstName, lastName, role, phone, gymId } = body;
if (!id) { if (!id) {
return NextResponse.json( 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); 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);
@ -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, { await db.updateUser(id, {
email: email || existingUser.email, email: email ?? existingUser.email,
firstName: firstName || existingUser.firstName, firstName: firstName ?? existingUser.firstName,
lastName: lastName || existingUser.lastName, lastName: lastName ?? existingUser.lastName,
role: role || existingUser.role, role: role ?? existingUser.role,
phone: phone !== undefined ? phone : existingUser.phone, phone: phone !== undefined ? phone : existingUser.phone,
}); gymId: gymId !== undefined ? gymId : existingUser.gymId,
} as any);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } 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 = 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 // 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, firstName, lastName, password, phone, role, createdAt, updatedAt) INSERT INTO users (id, email, first_name, last_name, password, phone, role, gym_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
stmt.run( stmt.run(
@ -97,11 +113,57 @@ export async function POST(req: Request) {
"", // Clerk handles authentication "", // Clerk handles authentication
null, // phone null, // phone
role, role,
gymId,
now, now,
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(); db.close();
break; break;
} }
@ -124,15 +186,21 @@ export async function POST(req: Request) {
); );
} }
// Determine role from metadata // Determine role & gym from metadata
const role = 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 // 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 = ?, firstName = ?, lastName = ?, role = ?, updatedAt = ? SET email = ?, first_name = ?, last_name = ?, role = ?, gym_id = ?, updated_at = ?
WHERE id = ? WHERE id = ?
`); `);
@ -141,10 +209,33 @@ 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?.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`); console.log(`✅ User ${id} updated in database`);
db.close(); db.close();
break; break;

View File

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

View File

@ -16,13 +16,12 @@ 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;
@ -30,6 +29,7 @@ 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;
@ -61,6 +61,31 @@ 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(
() => [ () => [
@ -95,16 +120,34 @@ 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 = 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 ( 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} {label}
</span> </span>
); );
}, },
minWidth: 120, 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", headerName: "Phone",
field: "phone", field: "phone",
@ -119,7 +162,8 @@ export function UserGrid({
filter: "agTextColumnFilter", filter: "agTextColumnFilter",
sortable: true, sortable: true,
cellRenderer: (params: any) => { 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 = { const membershipColors = {
vip: "bg-yellow-100 text-yellow-800 border-yellow-200", vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
@ -130,10 +174,13 @@ 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 = params.value.charAt(0).toUpperCase() + params.value.slice(1); const label =
params.value.charAt(0).toUpperCase() + params.value.slice(1);
return ( 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} {label}
</span> </span>
); );
@ -146,7 +193,8 @@ export function UserGrid({
filter: "agTextColumnFilter", filter: "agTextColumnFilter",
sortable: true, sortable: true,
cellRenderer: (params: any) => { 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 = { const statusColors = {
active: "bg-green-100 text-green-800", active: "bg-green-100 text-green-800",
@ -158,10 +206,13 @@ 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 = params.value.charAt(0).toUpperCase() + params.value.slice(1); const label =
params.value.charAt(0).toUpperCase() + params.value.slice(1);
return ( 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} {label}
</span> </span>
); );
@ -178,8 +229,10 @@ export function UserGrid({
return <span className="text-gray-400"></span>; return <span className="text-gray-400"></span>;
} }
const checkInTime = params.data.checkInTime ? new Date(params.data.checkInTime) : null; const checkInTime = params.data.checkInTime
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : ''; ? new Date(params.data.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">
@ -231,7 +284,9 @@ 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 = 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); setSelectedUsers(selectedData);
if (selectedData.length === 1 && onUserSelect) { if (selectedData.length === 1 && onUserSelect) {
onUserSelect(selectedData[0]); onUserSelect(selectedData[0]);

View File

@ -5,6 +5,7 @@ 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;
@ -13,6 +14,7 @@ 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;
@ -29,6 +31,7 @@ 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");
@ -41,8 +44,32 @@ 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]);
@ -74,6 +101,7 @@ 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);
}; };
@ -150,10 +178,14 @@ export function UserManagement() {
try { try {
if (selectedUser) { if (selectedUser) {
// Update existing user // Update existing user
const response = await fetch("/api/users", { const response = await fetch("/api/admin/set-user-metadata", {
method: "PUT", method: "POST",
headers: { "Content-Type": "application/json" }, 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) { if (response.ok) {
setIsEditing(false); setIsEditing(false);
@ -164,10 +196,14 @@ export function UserManagement() {
} }
} else { } else {
// Create (Invite) new user // Create (Invite) new user
const response = await fetch("/api/users", { const response = await fetch("/api/invitations", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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) { if (response.ok) {
@ -232,6 +268,7 @@ 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);
@ -308,7 +345,9 @@ 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">{selectedUser ? 'Edit User' : 'Invite New User'}</h3> <h3 className="text-lg font-semibold mb-4">
{selectedUser ? "Edit User" : "Invite New User"}
</h3>
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
@ -389,6 +428,26 @@ 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"
@ -404,7 +463,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>
@ -513,9 +572,7 @@ 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( ? new Date(selectedUser.lastCheckInTime).toLocaleString()
selectedUser.lastCheckInTime,
).toLocaleString()
: "Never"} : "Never"}
</p> </p>
<p> <p>

View File

@ -1,4 +1,4 @@
import React, { useState } from "react"; import React, { useState, useEffect } from "react";
import { import {
View, View,
Text, Text,
@ -18,6 +18,32 @@ 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: "",
@ -41,6 +67,26 @@ 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),
@ -59,10 +105,6 @@ 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) {
@ -213,6 +255,55 @@ 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}

View File

@ -1,5 +1,14 @@
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from "react-native"; import {
import { useUser, useClerk } from "@clerk/clerk-expo"; 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 { 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";
@ -21,14 +30,55 @@ export default function ProfileScreen() {
}; };
const confirmSignOut = () => { const confirmSignOut = () => {
Alert.alert( Alert.alert("Sign Out", "Are you sure you want to sign out?", [
"Sign Out",
"Are you sure you want to sign out?",
[
{ text: "Cancel", style: "cancel" }, { text: "Cancel", style: "cancel" },
{ text: "Sign Out", style: "destructive", onPress: handleSignOut }, { 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 (
@ -48,7 +98,9 @@ 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}>{user?.primaryEmailAddress?.emailAddress}</Text> <Text style={styles.email}>
{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>
@ -59,39 +111,144 @@ 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 style={styles.infoRow} onPress={() => router.push('/personal-details')}> <TouchableOpacity
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 name="person-outline" size={20} color={theme.colors.primary} /> <Ionicons
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 name="chevron-forward" size={20} color={theme.colors.gray400} /> <Ionicons
name="chevron-forward"
size={20}
color={theme.colors.gray400}
/>
</TouchableOpacity> </TouchableOpacity>
<View style={styles.divider} /> <View style={styles.divider} />
<TouchableOpacity style={styles.infoRow} onPress={() => router.push('/fitness-profile')}> <TouchableOpacity
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 name="fitness-outline" size={20} color={theme.colors.success} /> <Ionicons
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 name="chevron-forward" size={20} color={theme.colors.gray400} /> <Ionicons
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 name="notifications-outline" size={20} color={theme.colors.warning} /> <Ionicons
name="notifications-outline"
size={20}
color={theme.colors.warning}
/>
</LinearGradient> </LinearGradient>
<Text style={styles.infoLabel}>Notifications</Text> <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> </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}>
@ -99,24 +256,43 @@ 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 name="help-circle-outline" size={20} color={theme.colors.secondary} /> <Ionicons
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 name="chevron-forward" size={20} color={theme.colors.gray400} /> <Ionicons
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(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} 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> </LinearGradient>
<Text style={styles.infoLabel}>Privacy & Security</Text> <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> </TouchableOpacity>
</View> </View>
</View> </View>
@ -145,13 +321,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: {
@ -159,28 +335,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,
@ -188,23 +364,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,
@ -234,16 +410,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: {
@ -261,7 +437,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,

View File

@ -1,29 +1,30 @@
export const API_BASE_URL = __DEV__ export const API_BASE_URL = __DEV__
? 'https://e0877d294c41.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`,
}, },
} };

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(), 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", { enum: ["superAdmin", "admin", "trainer", "client"] }) role: text("role", {
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()),
@ -144,7 +166,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
@ -198,6 +220,24 @@ 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")
@ -243,4 +283,3 @@ 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;

View File

@ -1,115 +1,155 @@
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" | "generalUser";
imageUrl?: string gymId?: string;
createdAt: Date imageUrl?: string;
updatedAt: Date createdAt: 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: "sedentary" | "lightly_active" | "moderately_active" | "very_active" | "extremely_active" activityLevel:
fitnessGoals: string[] | "sedentary"
exerciseHabits: string | "lightly_active"
dietHabits: string | "moderately_active"
medicalConditions: string | "very_active"
allergies?: string | "extremely_active";
injuries?: string fitnessGoals: string[];
createdAt: Date exerciseHabits: string;
updatedAt: Date dietHabits: string;
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: "weight_target" | "strength_milestone" | "endurance_target" | "flexibility_goal" | "habit_building" | "custom" goalType:
title: string | "weight_target"
description?: string | "strength_milestone"
targetValue?: number | "endurance_target"
currentValue?: number | "flexibility_goal"
unit?: string | "habit_building"
startDate: Date | "custom";
targetDate?: Date title: string;
completedDate?: Date description?: string;
status: "active" | "completed" | "abandoned" | "paused" targetValue?: number;
progress: number currentValue?: number;
priority: "low" | "medium" | "high" unit?: string;
notes?: string startDate: Date;
createdAt: Date targetDate?: Date;
updatedAt: Date completedDate?: 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;
} }