From d3a36b61035d40deca77888cb00d9cd604866da0 Mon Sep 17 00:00:00 2001 From: echo Date: Sat, 13 Dec 2025 06:26:23 +0100 Subject: [PATCH] working on onboarding flow --- apps/admin/data/fitai.db | Bin 69632 -> 77824 bytes apps/admin/src/app/api/admin/clients/route.ts | 84 +++ .../app/api/admin/set-user-metadata/route.ts | 166 +++++ apps/admin/src/app/api/admin/stats/route.ts | 74 ++- .../admin/src/app/api/admin/trainers/route.ts | 71 ++ apps/admin/src/app/api/gyms/route.ts | 174 +++++ apps/admin/src/app/api/invitations/route.ts | 148 +++++ apps/admin/src/app/api/users/gym/route.ts | 55 ++ apps/admin/src/app/api/users/route.ts | 114 +++- apps/admin/src/app/api/webhooks/route.ts | 107 ++- apps/admin/src/app/settings/page.tsx | 615 +++++++++++++----- apps/admin/src/components/users/UserGrid.tsx | 81 ++- .../src/components/users/UserManagement.tsx | 85 ++- apps/mobile/src/app/(auth)/onboarding.tsx | 101 ++- apps/mobile/src/app/(tabs)/profile.tsx | 272 ++++++-- apps/mobile/src/config/api.ts | 29 +- onboarding.md | 10 + packages/database/src/schema.ts | 45 +- packages/shared/src/types/index.ts | 224 ++++--- 19 files changed, 2062 insertions(+), 393 deletions(-) create mode 100644 apps/admin/src/app/api/admin/clients/route.ts create mode 100644 apps/admin/src/app/api/admin/set-user-metadata/route.ts create mode 100644 apps/admin/src/app/api/admin/trainers/route.ts create mode 100644 apps/admin/src/app/api/gyms/route.ts create mode 100644 apps/admin/src/app/api/invitations/route.ts create mode 100644 apps/admin/src/app/api/users/gym/route.ts create mode 100644 onboarding.md diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 5fbc56436d81d8ac9f6d8884bac6b03da2d4e2ac..3bba925cd8ba1390cf6df1e0422a212516cf2471 100644 GIT binary patch delta 853 zcmZozz|!!5WrDPzA_D`1Fc8B4$3z`tM#YT@OZfR%c{efeDeyb;J>lKN*R@$tU;*#s zO|qxzZ9YZuFJs_#mX_ybkYtHq^|GYu_GjUCc+6Ek!4lJj%JfKvI%Ihm<>C7Cwg!a+Ei znakHyT$z_a9A>ge%pGz9I<*Y#Y z=|Fv@4E$U9&+$*?f6ZUISL;?K!XPAtjH&r=9-jR?`>4UshHl@?%N)N+OVywu`s9fhP4u=6Kh zn6GHTI9p6O(H%mDGr=^Mn3ZRjRb-{4=2zxb<`z_9*Q^iH<|6|k{TMlP1qDGiXOtS3 z8JJWR=M-m|rWWPpCMG5&!7Y>ms%8lM2O+sYKHxvZz<-GU4gdAcf&p9j?L?S$8PVO& Q3=%|l6O%BrE+@KO0H1pN0RR91 delta 196 zcmZp8z|ydQWrDPzGy?;JAP~a<`$QdMM(K?SOZa(McsDWdDeyb;J>lK7Sy5pT@8nIg zr}(YIcJnV|NOhK$=Vg#&VVPX0uQ2(Ztde*^YDrOkS#fq|a!zJyUP-2P=y?#{{6tQH zk%xspmw`W*{|^5o{_UFu9b)+>=ju-ficV$V-^zcEe=7fLplB;Ui#)S4BhWPI&0Fk! l4}hG-%Avu)f10P1$BZMHLu0d|fEdTbqt@G#IT+`%0{}34Hq-zB diff --git a/apps/admin/src/app/api/admin/clients/route.ts b/apps/admin/src/app/api/admin/clients/route.ts new file mode 100644 index 0000000..fa6cf22 --- /dev/null +++ b/apps/admin/src/app/api/admin/clients/route.ts @@ -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= 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 }); + } +} diff --git a/apps/admin/src/app/api/admin/set-user-metadata/route.ts b/apps/admin/src/app/api/admin/set-user-metadata/route.ts new file mode 100644 index 0000000..9ec2187 --- /dev/null +++ b/apps/admin/src/app/api/admin/set-user-metadata/route.ts @@ -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 = { + ...(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 }); + } +} diff --git a/apps/admin/src/app/api/admin/stats/route.ts b/apps/admin/src/app/api/admin/stats/route.ts index a8753fa..4c13fca 100644 --- a/apps/admin/src/app/api/admin/stats/route.ts +++ b/apps/admin/src/app/api/admin/stats/route.ts @@ -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 }); + } } diff --git a/apps/admin/src/app/api/admin/trainers/route.ts b/apps/admin/src/app/api/admin/trainers/route.ts new file mode 100644 index 0000000..d112055 --- /dev/null +++ b/apps/admin/src/app/api/admin/trainers/route.ts @@ -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= 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 }); + } +} diff --git a/apps/admin/src/app/api/gyms/route.ts b/apps/admin/src/app/api/gyms/route.ts new file mode 100644 index 0000000..9aef2f2 --- /dev/null +++ b/apps/admin/src/app/api/gyms/route.ts @@ -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) + ); +} diff --git a/apps/admin/src/app/api/invitations/route.ts b/apps/admin/src/app/api/invitations/route.ts new file mode 100644 index 0000000..60f2449 --- /dev/null +++ b/apps/admin/src/app/api/invitations/route.ts @@ -0,0 +1,148 @@ +import { NextResponse } from "next/server"; +import { auth, clerkClient } from "@clerk/nextjs/server"; + +/** + * POST /api/invitations + * + * Create a Clerk-managed invitation and store role/gym context in publicMetadata. + * This endpoint does not implement invitation acceptance; Clerk’s acceptance link flow and webhooks will handle that. + * + * Body: { + * inviteeEmail: string, + * roleAssigned: 'trainer' | 'client' | 'admin' + * } + * + * Rules: + * - admin: can invite trainer or client; the gymId is taken from inviter's publicMetadata or user record. + * - trainer: can invite client; the gymId is taken from inviter's publicMetadata or user record. + * - superAdmin: can invite admin; requires `gymId` in body, or falls back to inviter's `gymId` if present. + * + * Returns Clerk invitation payload. + */ +export async function POST(req: Request) { + try { + const { userId } = await auth(); + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const inviteeEmail = String(body.inviteeEmail ?? "") + .trim() + .toLowerCase(); + const roleAssigned = String(body.roleAssigned ?? "").trim() as + | "trainer" + | "client" + | "admin"; + let requestedGymId: string | null = body.gymId ? String(body.gymId) : null; + + if (!inviteeEmail || !roleAssigned) { + return NextResponse.json( + { error: "inviteeEmail and roleAssigned are required" }, + { status: 400 }, + ); + } + + // Fetch inviter user from Clerk + const client = await clerkClient(); + const inviter = await client.users.getUser(userId); + const inviterRole = + (inviter.publicMetadata?.role as + | "superAdmin" + | "admin" + | "trainer" + | "client" + | "generalUser") ?? "client"; + const inviterGymId = + (inviter.publicMetadata?.gymId as string | undefined) ?? undefined; + + // Enforce role-based rules and resolve target gymId for the invitation + let gymIdForInvite: string | null = null; + switch (inviterRole) { + case "admin": { + if (roleAssigned !== "trainer" && roleAssigned !== "client") { + return NextResponse.json( + { error: "Admin can only invite trainer or client" }, + { status: 403 }, + ); + } + if (!inviterGymId) { + return NextResponse.json( + { error: "Inviter admin must be assigned to a gym" }, + { status: 400 }, + ); + } + gymIdForInvite = inviterGymId; + break; + } + case "trainer": { + if (roleAssigned !== "client") { + return NextResponse.json( + { error: "Trainer can only invite client" }, + { status: 403 }, + ); + } + if (!inviterGymId) { + return NextResponse.json( + { error: "Inviter trainer must be assigned to a gym" }, + { status: 400 }, + ); + } + gymIdForInvite = inviterGymId; + break; + } + case "superAdmin": { + if ( + roleAssigned !== "admin" && + roleAssigned !== "trainer" && + roleAssigned !== "client" + ) { + return NextResponse.json( + { error: "Invalid roleAssigned for SuperAdmin" }, + { status: 400 }, + ); + } + // Prefer explicitly provided gymId, otherwise fall back to inviter's gymId if present + gymIdForInvite = requestedGymId || inviterGymId || null; + if (!gymIdForInvite) { + return NextResponse.json( + { error: "gymId is required for SuperAdmin when inviting" }, + { status: 400 }, + ); + } + break; + } + default: { + return NextResponse.json( + { error: "Inviter role not permitted to create invitations" }, + { status: 403 }, + ); + } + } + + // Create Clerk invitation with metadata needed by webhook to assign role & gym + // reuse existing Clerk client instance + const invitation = await client.invitations.createInvitation({ + emailAddress: inviteeEmail, + publicMetadata: { + roleAssigned, + gymId: gymIdForInvite, + inviterUserId: inviter.id, + }, + }); + + return NextResponse.json(invitation, { status: 201 }); + } catch (error: any) { + // Surface Clerk errors where possible + const message = + error?.errors?.[0]?.message || + error?.message || + "Failed to create invitation"; + console.error("POST /api/invitations error:", error); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/apps/admin/src/app/api/users/gym/route.ts b/apps/admin/src/app/api/users/gym/route.ts new file mode 100644 index 0000000..98ae14c --- /dev/null +++ b/apps/admin/src/app/api/users/gym/route.ts @@ -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 }) + } +} diff --git a/apps/admin/src/app/api/users/route.ts b/apps/admin/src/app/api/users/route.ts index 85babd7..2737e6f 100644 --- a/apps/admin/src/app/api/users/route.ts +++ b/apps/admin/src/app/api/users/route.ts @@ -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 = { + 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 = {}; + + 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) { diff --git a/apps/admin/src/app/api/webhooks/route.ts b/apps/admin/src/app/api/webhooks/route.ts index 2e742f5..e67feae 100644 --- a/apps/admin/src/app/api/webhooks/route.ts +++ b/apps/admin/src/app/api/webhooks/route.ts @@ -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; diff --git a/apps/admin/src/app/settings/page.tsx b/apps/admin/src/app/settings/page.tsx index 0e594a6..8bce59b 100644 --- a/apps/admin/src/app/settings/page.tsx +++ b/apps/admin/src/app/settings/page.tsx @@ -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([]); - const [loading, setLoading] = useState(true); - const [creatingBackup, setCreatingBackup] = useState(false); - const [restoring, setRestoring] = useState(null); - const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); + const { user } = useUser(); + const [backups, setBackups] = useState([]); + const [loading, setLoading] = useState(true); + const [creatingBackup, setCreatingBackup] = useState(false); + const [restoring, setRestoring] = useState(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([]); + const [gymsLoading, setGymsLoading] = useState(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 ( -
+ 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 ( +
+
+

Settings

+

+ Manage your application settings and database. +

+
+ + {/* Gym Picker */} +
+
+
+
+ +
-

Settings

-

Manage your application settings and database.

-
- -
-
-
-
- -
-
-

Database Management

-

Create backups and restore your database

-
-
- -
- - {message && ( -
- {message.type === 'success' ? : } - {message.text} -
+

+ Gym Selection +

+

+ Select your gym or proceed without a gym +

+

+ {user ? ( + <> + Current role:{" "} + + {String(user.publicMetadata?.role ?? "unknown")} + + {" • "} + Gym ID:{" "} + + {String(user.publicMetadata?.gymId ?? "none")} + + + ) : ( + "Loading user metadata..." )} - -

- - - - - - - - - - - {loading ? ( - - - - ) : backups.length === 0 ? ( - - - - ) : ( - backups.map((backup) => ( - - - - - - - )) - )} - -
FilenameSizeCreated AtActions
- Loading backups... -
- No backups found -
{backup.name}{formatSize(backup.size)}{formatDate(backup.createdAt)} - -
-
+

+
+
+ + +
- ); + + {gymMessage && ( +
+ {gymMessage.type === "success" ? ( + + ) : ( + + )} + {gymMessage.text} +
+ )} + {showCreateGym && ( +
+
+

Create Gym

+
{ + 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); + } + }} + > +
+ + setGymName(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2" + required + /> +
+
+ + setGymLocation(e.target.value)} + className="w-full border border-gray-300 rounded px-3 py-2" + placeholder="Enter location" + /> +
+
+ + +
+
+
+
+ )} + +
+
+
+

+ Proceed without gym +

+

+ You can select a gym later. +

+
+ +
+ + {gymsLoading ? ( +
+ + Loading gyms... +
+ ) : gyms.length === 0 ? ( +
+ No active gyms found. +
+ ) : ( + gyms.map((gym) => ( +
+
+

{gym.name}

+

+ {gym.location || "No location provided"} +

+
+ +
+ )) + )} +
+
+ +
+
+
+
+ +
+
+

+ Database Management +

+

+ Create backups and restore your database +

+
+
+ +
+ + {message && ( +
+ {message.type === "success" ? ( + + ) : ( + + )} + {message.text} +
+ )} + +
+ + + + + + + + + + + {loading ? ( + + + + ) : backups.length === 0 ? ( + + + + ) : ( + backups.map((backup) => ( + + + + + + + )) + )} + +
+ Filename + Size + Created At + + Actions +
+ Loading backups... +
+ No backups found +
+ {backup.name} + + {formatSize(backup.size)} + + {formatDate(backup.createdAt)} + + +
+
+
+
+ ); } diff --git a/apps/admin/src/components/users/UserGrid.tsx b/apps/admin/src/components/users/UserGrid.tsx index cc3ba54..d3a23ca 100644 --- a/apps/admin/src/components/users/UserGrid.tsx +++ b/apps/admin/src/components/users/UserGrid.tsx @@ -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([]); const [searchQuery, setSearchQuery] = useState(""); + const [gymNames, setGymNames] = useState>({}); + + 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 = {}; + 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[] = 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 ( - + {label} ); }, 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 N/A; + if (!params.value || params.value === "N/A") + return N/A; 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 ( - + {label} ); @@ -146,7 +193,8 @@ export function UserGrid({ filter: "agTextColumnFilter", sortable: true, cellRenderer: (params: any) => { - if (!params.value || params.value === "N/A") return N/A; + if (!params.value || params.value === "N/A") + return N/A; 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 ( - + {label} ); @@ -178,8 +229,10 @@ export function UserGrid({ return ; } - 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 ( @@ -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]); diff --git a/apps/admin/src/components/users/UserManagement.tsx b/apps/admin/src/components/users/UserManagement.tsx index c44b28b..6e2d519 100644 --- a/apps/admin/src/components/users/UserManagement.tsx +++ b/apps/admin/src/components/users/UserManagement.tsx @@ -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([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("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>([]); + + // 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 && (
-

{selectedUser ? 'Edit User' : 'Invite New User'}

+

+ {selectedUser ? "Edit User" : "Invite New User"} +

{ 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. */} @@ -389,6 +428,26 @@ export function UserManagement() { className="w-full border border-gray-300 rounded px-3 py-2" />
+
+ + +

+ Select an active gym or proceed without a gym. +

+
@@ -499,8 +558,8 @@ export function UserManagement() { Last Visit:{" "} {selectedUser.client.lastVisit ? new Date( - selectedUser.client.lastVisit, - ).toLocaleDateString() + selectedUser.client.lastVisit, + ).toLocaleDateString() : "Never"}

@@ -513,9 +572,7 @@ export function UserManagement() {

Last Check-In:{" "} {selectedUser.lastCheckInTime - ? new Date( - selectedUser.lastCheckInTime, - ).toLocaleString() + ? new Date(selectedUser.lastCheckInTime).toLocaleString() : "Never"}

diff --git a/apps/mobile/src/app/(auth)/onboarding.tsx b/apps/mobile/src/app/(auth)/onboarding.tsx index e68e812..cc5af70 100644 --- a/apps/mobile/src/app/(auth)/onboarding.tsx +++ b/apps/mobile/src/app/(auth)/onboarding.tsx @@ -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(false); + const [selectedGymId, setSelectedGymId] = useState(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" /> + Select a Gym + {gymsLoading ? ( + + ) : ( + + + setSelectedGymId(null)} + > + + Proceed without gym + + + {gyms.map((gym) => ( + setSelectedGymId(gym.id)} + > + + {gym.name} + + + ))} + + + )} + { - 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(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() { {user?.fullName || "User"} - {user?.primaryEmailAddress?.emailAddress} + + {user?.primaryEmailAddress?.emailAddress} + Premium Member @@ -59,39 +111,144 @@ export default function ProfileScreen() { Account - router.push('/personal-details')}> + router.push("/personal-details")} + > - + Personal Details - + - router.push('/fitness-profile')}> + router.push("/fitness-profile")} + > - + Fitness Profile - + - + Notifications - + + + {/* Gym Selection */} + + + Gym + + + Refresh Gyms + + + + + {gymsLoading ? ( + + ) : ( + + + setSelectedGymId(null)} + > + Proceed without gym + + {gyms.map((gym) => ( + setSelectedGymId(gym.id)} + > + {gym.name} + + ))} + + + )} + + + + } + /> + + @@ -99,24 +256,43 @@ export default function ProfileScreen() { - + Help Center - + - + Privacy & Security - + @@ -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, diff --git a/apps/mobile/src/config/api.ts b/apps/mobile/src/config/api.ts index 1f13674..ca38de0 100644 --- a/apps/mobile/src/config/api.ts +++ b/apps/mobile/src/config/api.ts @@ -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`, }, -} +}; diff --git a/onboarding.md b/onboarding.md new file mode 100644 index 0000000..54797b1 --- /dev/null +++ b/onboarding.md @@ -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. diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 747f7e0..eaebe9d 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -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; - diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6af67ac..a68c5bb 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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 -} \ No newline at end of file + 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; +}