Compare commits

...

3 Commits

Author SHA1 Message Date
339d798a88 now gym in UI displays properly 2025-12-18 19:14:40 +01:00
6580564767 build errors fixed 2025-12-18 17:19:11 +01:00
d3a36b6103 working on onboarding flow 2025-12-13 06:26:23 +01:00
23 changed files with 2989 additions and 905 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 });
}
const stats = await db.getDashboardStats()
return NextResponse.json(stats)
} catch (error) {
console.error('Dashboard stats error:', error)
return new NextResponse('Internal Server Error', { status: 500 })
} }
if (user.role === "admin" && !user.gymId) {
return new NextResponse("Admin gymId not set", { status: 400 });
}
const url = new URL(req.url);
const searchParams = url.searchParams;
let targetGymId: string | null = null;
if (user.role === "admin") {
targetGymId = user.gymId ?? null;
} else if (user.role === "superAdmin") {
targetGymId = searchParams.get("gymId");
}
const allUsers = await db.getAllUsers();
const allClients = await db.getAllClients();
const usersById = new Map(allUsers.map((u) => [u.id, u]));
const filteredUsers = targetGymId
? allUsers.filter((u) => u.gymId === targetGymId)
: allUsers;
const filteredClients = targetGymId
? allClients.filter((c) => {
const u = usersById.get(c.userId);
return u?.gymId === targetGymId;
})
: allClients;
const stats = {
totalUsers: filteredUsers.length,
activeClients: filteredClients.filter(
(c) => c.membershipStatus === "active",
).length,
totalRevenue: 0,
revenueGrowth: 0,
};
return NextResponse.json(stats);
} catch (error) {
console.error("Dashboard stats error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
} }

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 as string;
} else if (user.role === "superAdmin") {
targetGymId = requestedGymId;
}
// Fetch all users and filter to trainers
const allUsers = await db.getAllUsers();
let trainers = allUsers.filter((u) => u.role === "trainer");
// Scope by gym when required/provided
if (targetGymId) {
trainers = trainers.filter((t) => t.gymId === targetGymId);
}
// Minimal payload suitable for listing
const payload = trainers.map((t) => ({
id: t.id,
email: t.email,
firstName: t.firstName,
lastName: t.lastName,
gymId: t.gymId ?? null,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
}));
return NextResponse.json(payload);
} catch (error) {
console.error("GET /api/admin/trainers error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}

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,81 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import { db, users as usersTable, eq, sql } from "@fitai/database";
/**
* PATCH /api/users/gym
* Body: { gymId: string | null }
* - Updates the current authenticated user's gym selection.
* - gymId can be null to proceed without a gym.
* - If gymId is provided, it must exist and be active.
*/
export async function PATCH(req: Request) {
try {
const { userId } = await auth();
console.log("PATCH /api/users/gym auth userId:", userId);
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
const body = await req.json().catch(() => null);
if (!body || typeof body !== "object" || !("gymId" in body)) {
return NextResponse.json(
{ error: "gymId is required in body (can be null)" },
{ status: 400 },
);
}
const gymId = body.gymId === null ? null : String(body.gymId);
console.log("PATCH /api/users/gym parsed gymId from body:", gymId);
// Ensure user exists
console.log("PATCH /api/users/gym fetching user by id:", userId);
const user = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId))
.get();
console.log("PATCH /api/users/gym fetched user:", user);
if (!user) return new NextResponse("User not found", { status: 404 });
// Validate gym when provided
if (gymId) {
console.log("PATCH /api/users/gym validating gym:", gymId);
const rows = await db.all(
sql`SELECT status FROM gyms WHERE id = ${gymId} LIMIT 1`,
);
console.log("PATCH /api/users/gym validation query result rows:", rows);
const gym = rows?.[0] as { status?: string } | undefined;
if (!gym) {
console.log("PATCH /api/users/gym validation: gym not found");
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
}
if (gym.status !== "active") {
console.log("PATCH /api/users/gym validation: gym not active", gym);
return NextResponse.json(
{ error: "Gym is not active" },
{ status: 400 },
);
}
}
// Update user's gym selection
console.log("PATCH /api/users/gym updating user gym_id:", {
userId,
gymId,
});
await db.run(
sql`UPDATE users SET gym_id = ${gymId ?? null}, updated_at = ${new Date()} WHERE id = ${userId}`,
);
console.log("PATCH /api/users/gym update completed");
const updated = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, userId))
.get();
console.log("PATCH /api/users/gym returning updated user:", updated);
return NextResponse.json(updated);
} catch (error) {
console.error("PATCH /users/gym error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getDatabase } from "../../../lib/database/index"; import { getDatabase } from "../../../lib/database/index";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { auth, clerkClient } from "@clerk/nextjs/server"; import { auth, clerkClient } from "@clerk/nextjs/server";
import { db as rawDb, sql } from "@fitai/database";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@ -11,9 +12,47 @@ export async function GET(request: NextRequest) {
let users = await db.getAllUsers(); let users = await db.getAllUsers();
// Hydrate gymId from raw DB to ensure consistency with writes
const rawUserRows = await rawDb.all(sql`SELECT id, gym_id FROM users`);
const gymById = new Map<string, string | null>(
(rawUserRows || []).map((r: any) => [
r.id as string,
(r.gym_id as string | null) ?? null,
]),
);
// Load gym names for mapping gymId -> gymName
const gymRows = await rawDb.all(sql`SELECT id, name FROM gyms`);
const gymNames = new Map<string, string>(
(gymRows || [])
.filter((g: any) => !!g && typeof g.id === "string")
.map((g: any) => [
g.id as string,
(g.name as string) || (g.id as string),
]),
);
console.log(
"GET /api/users: total users fetched from DB:",
Array.isArray(users) ? users.length : 0,
);
if (role) { if (role) {
users = users.filter((user) => user.role === role); users = users.filter((user) => user.role === role);
} }
console.log(
"GET /api/users: role filter:",
role,
"users after filter:",
Array.isArray(users) ? users.length : 0,
"sample:",
users && users[0]
? {
id: users[0].id,
role: users[0].role,
gymId: (users as any)[0].gymId,
}
: null,
);
const usersWithClients = await Promise.all( const usersWithClients = await Promise.all(
users.map(async (user) => { users.map(async (user) => {
@ -45,29 +84,50 @@ export async function GET(request: NextRequest) {
const weekAgo = new Date(); const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7); weekAgo.setDate(weekAgo.getDate() - 7);
checkInsThisWeek = attendanceHistory.filter( checkInsThisWeek = attendanceHistory.filter(
a => new Date(a.checkInTime) >= weekAgo (a) => new Date(a.checkInTime) >= weekAgo,
).length; ).length;
// Calculate check-ins in last 30 days // Calculate check-ins in last 30 days
const monthAgo = new Date(); const monthAgo = new Date();
monthAgo.setDate(monthAgo.getDate() - 30); monthAgo.setDate(monthAgo.getDate() - 30);
checkInsThisMonth = attendanceHistory.filter( checkInsThisMonth = attendanceHistory.filter(
a => new Date(a.checkInTime) >= monthAgo (a) => new Date(a.checkInTime) >= monthAgo,
).length; ).length;
} }
return { return {
...userWithoutPassword, ...userWithoutPassword,
// Override gymId from raw DB hydration to avoid undefined from Drizzle mapping
gymId: gymById.get(user.id) ?? (user as any).gymId ?? undefined,
// Provide gymName mapped from gyms table
gymName: (() => {
const gid =
gymById.get(user.id) ?? (user as any).gymId ?? undefined;
if (!gid) return null;
return gymNames.get(gid) ?? null;
})(),
client, client,
isCheckedIn, isCheckedIn,
checkInTime, checkInTime,
lastCheckInTime, lastCheckInTime,
checkInsThisWeek, checkInsThisWeek,
checkInsThisMonth checkInsThisMonth,
}; };
}), }),
); );
console.log(
"GET /api/users: responding users count:",
Array.isArray(usersWithClients) ? usersWithClients.length : 0,
"sample:",
usersWithClients && usersWithClients[0]
? {
id: usersWithClients[0].id,
role: usersWithClients[0].role,
gymId: (usersWithClients as any)[0].gymId,
}
: null,
);
return NextResponse.json({ users: usersWithClients }); return NextResponse.json({ users: usersWithClients });
} catch (error) { } catch (error) {
console.error("Get users error:", error); console.error("Get users error:", error);
@ -105,7 +165,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 +186,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 +215,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 +249,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 +275,16 @@ 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;
console.log("PUT /api/users received body:", {
id,
email,
firstName,
lastName,
role,
phone,
gymId,
});
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
@ -214,12 +293,43 @@ export async function PUT(request: NextRequest) {
); );
} }
// Get existing user // Authenticate requester
const { userId: requesterId } = await auth();
if (!requesterId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Fetch requester and target user
const requester = await db.getUserById(requesterId);
if (!requester) {
return NextResponse.json(
{ error: "Requester not found" },
{ status: 403 },
);
}
const existingUser = await db.getUserById(id); 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,16 +341,86 @@ export async function PUT(request: NextRequest) {
} }
} }
// Update user // Update Clerk publicMetadata (role/gymId) to propagate via webhook
await db.updateUser(id, { // Note: Only update metadata when a change is requested
email: email || existingUser.email, try {
firstName: firstName || existingUser.firstName, const client = await clerkClient();
lastName: lastName || existingUser.lastName, const publicMetadata: Record<string, unknown> = {};
role: role || existingUser.role, console.log("PUT /api/users preparing Clerk metadata update:", {
phone: phone !== undefined ? phone : existingUser.phone, targetUserId: id,
}); role,
gymId,
});
return NextResponse.json({ success: true }); if (role) {
publicMetadata.role = role;
}
if (gymId !== undefined) {
publicMetadata.gymId = gymId === null ? null : String(gymId);
}
if (Object.keys(publicMetadata).length > 0) {
console.log(
"PUT /api/users calling Clerk updateUser with metadata:",
publicMetadata,
);
const clerkResult = await client.users.updateUser(id, {
publicMetadata,
});
console.log("PUT /api/users Clerk updateUser result:", {
id: clerkResult.id,
role: clerkResult.publicMetadata?.role,
gymId: clerkResult.publicMetadata?.gymId,
});
} else {
console.log("PUT /api/users no Clerk metadata changes requested");
}
} catch (clerkErr: any) {
console.error("Clerk metadata update error:", clerkErr);
return NextResponse.json(
{ error: "Failed to update role/gym in identity provider" },
{ status: 500 },
);
}
// Update local DB for immediate UI feedback (webhook will also sync)
console.log(
"PUT /api/users raw SQL updating local DB user gym_id and fields",
);
await rawDb.run(
sql`UPDATE users
SET email = ${email ?? existingUser.email},
first_name = ${firstName ?? existingUser.firstName},
last_name = ${lastName ?? existingUser.lastName},
role = ${role ?? existingUser.role},
phone = ${phone !== undefined && typeof phone === "string" ? phone : (existingUser.phone ?? null)},
gym_id = ${gymId !== undefined ? gymId : (existingUser.gymId ?? null)},
updated_at = ${Date.now()}
WHERE id = ${id}`,
);
// Read back the updated row to surface gym_id and confirm write
const updatedRow = await rawDb.get(
sql`SELECT id, email, first_name, last_name, role, phone, gym_id, created_at, updated_at FROM users WHERE id = ${id}`,
);
console.log("PUT /api/users raw DB row after update:", updatedRow);
const updatedUser = {
...existingUser,
email: email ?? existingUser.email,
firstName: firstName ?? existingUser.firstName,
lastName: lastName ?? existingUser.lastName,
role: role ?? existingUser.role,
phone: phone !== undefined ? phone : existingUser.phone,
gymId:
updatedRow?.gym_id !== undefined
? updatedRow.gym_id
: gymId !== undefined
? gymId
: existingUser.gymId,
};
console.log("PUT /api/users responding with updated user:", updatedUser);
return NextResponse.json({ user: updatedUser });
} catch (error) { } catch (error) {
console.error("Update user error:", error); console.error("Update user error:", error);
return NextResponse.json( return NextResponse.json(

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";
let gymId = (public_metadata?.gymId as string | null) ?? null;
const inviterUserId =
(public_metadata?.inviterUserId as string | undefined) ?? undefined;
const roleAssigned =
(public_metadata?.roleAssigned as
| "superAdmin"
| "admin"
| "trainer"
| "client"
| "generalUser"
| undefined) ?? role;
// Insert user into database with Clerk's user ID // Insert user into database with Clerk's user ID
const now = new Date().toISOString(); const now = new Date().toISOString();
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO users (id, email, 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 gym_id FROM users WHERE id = ?")
.get(inviterUserId) as { gym_id?: string } | undefined;
if (inviterGymRow?.gym_id) {
const inheritStmt = db.prepare(`
UPDATE users
SET gym_id = ?, updated_at = ?
WHERE id = ?
`);
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
gymId = inviterGymRow.gym_id;
}
}
console.log(
`✅ User ${id} created in database (role=${role}, gymId=${gymId ?? "null"})`,
);
db.close(); db.close();
break; break;
} }
@ -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";
let gymId = (public_metadata?.gymId as string | null) ?? null;
// Update user in database // Update user in database
const now = new Date().toISOString(); const now = new Date().toISOString();
const stmt = db.prepare(` const stmt = db.prepare(`
UPDATE users UPDATE users
SET email = ?, 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?.gym_id) {
const inheritStmt = db.prepare(`
UPDATE users
SET gym_id = ?, updated_at = ?
WHERE id = ?
`);
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
gymId = inviterGymRow.gym_id;
}
}
console.log(`✅ User ${id} updated in database`); console.log(`✅ User ${id} updated in database`);
db.close(); db.close();
break; break;

View File

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

View File

@ -16,13 +16,12 @@ function getTimeAgo(date: Date): string {
const diffHours = Math.floor(diffMins / 60); const 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,8 @@ interface User {
lastName: string; lastName: string;
role: string; role: string;
phone?: string; phone?: string;
gymId?: string;
gymName?: string | null;
createdAt: Date; createdAt: Date;
isCheckedIn?: boolean; isCheckedIn?: boolean;
checkInTime?: Date; checkInTime?: Date;
@ -61,6 +62,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 +121,36 @@ 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;
const gymName = params.data?.gymName;
if (!gymId && !gymName) return "None";
if (gymName) return gymName;
return gymNames[gymId] || gymId;
},
},
{ {
headerName: "Phone", headerName: "Phone",
field: "phone", field: "phone",
@ -119,7 +165,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 +177,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 +196,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 +209,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 +232,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 +287,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]);
@ -50,10 +77,33 @@ export function UserManagement() {
const fetchUsers = async () => { const fetchUsers = async () => {
setLoading(true); setLoading(true);
try { try {
const url = filter === "all" ? "/api/users" : `/api/users?role=${filter}`; const ts = Date.now();
const url =
filter === "all"
? `/api/users?ts=${ts}`
: `/api/users?role=${filter}&ts=${ts}`;
const response = await fetch(url); console.log("UserManagement.fetchUsers: fetching URL", url);
const response = await fetch(url, { cache: "no-store" });
console.log(
"UserManagement.fetchUsers: response.ok",
response.ok,
"status",
response.status,
);
const data = await response.json(); const data = await response.json();
console.log(
"UserManagement.fetchUsers: received users count",
Array.isArray(data.users) ? data.users.length : 0,
"sample",
data.users && data.users[0]
? {
id: data.users[0].id,
gymId: data.users[0].gymId,
role: data.users[0].role,
}
: null,
);
setUsers(data.users || []); setUsers(data.users || []);
} catch (error) { } catch (error) {
console.error("Failed to fetch users:", error); console.error("Failed to fetch users:", error);
@ -74,6 +124,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,24 +201,92 @@ export function UserManagement() {
try { try {
if (selectedUser) { if (selectedUser) {
// Update existing user // Update existing user
console.log(
"UserManagement.handleSaveEdit: sending PUT /api/users payload",
{
id: selectedUser.id,
email: editForm.email,
firstName: editForm.firstName,
lastName: editForm.lastName,
role: editForm.role,
phone: editForm.phone,
gymId: editForm.gymId === "" ? null : editForm.gymId,
},
);
const response = await fetch("/api/users", { const response = await fetch("/api/users", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: selectedUser.id, ...editForm }), body: JSON.stringify({
id: selectedUser.id,
email: editForm.email,
firstName: editForm.firstName,
lastName: editForm.lastName,
role: editForm.role,
phone: editForm.phone,
gymId: editForm.gymId === "" ? null : editForm.gymId,
}),
}); });
console.log(
"UserManagement.handleSaveEdit: PUT /api/users response.ok",
response.ok,
"status",
response.status,
);
if (response.ok) { if (response.ok) {
// Optimistically update local state so grid reflects changes immediately
setUsers((prev) =>
prev.map((u) =>
u.id === selectedUser.id
? {
...u,
email: editForm.email,
firstName: editForm.firstName,
lastName: editForm.lastName,
role: editForm.role,
phone: editForm.phone || undefined,
gymId: editForm.gymId === "" ? undefined : editForm.gymId,
}
: u,
),
);
setSelectedUser((prev) =>
prev
? {
...prev,
email: editForm.email,
firstName: editForm.firstName,
lastName: editForm.lastName,
role: editForm.role,
phone: editForm.phone || undefined,
gymId: editForm.gymId === "" ? undefined : editForm.gymId,
}
: prev,
);
setIsEditing(false); setIsEditing(false);
setEditForm(null); setEditForm(null);
// Still re-fetch from server to ensure consistency
console.log(
"UserManagement.handleSaveEdit: re-fetching users after successful edit",
);
fetchUsers(); fetchUsers();
} else { } else {
const errText = await response.text().catch(() => "");
console.error("UserManagement.handleSaveEdit: update failed", {
status: response.status,
body: errText,
});
alert("Error updating user"); alert("Error updating user");
} }
} else { } else {
// Create (Invite) new user // Create (Invite) new user
const response = await fetch("/api/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 +351,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 +428,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 +511,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 +546,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>
@ -499,8 +641,8 @@ export function UserManagement() {
<span className="font-medium">Last Visit:</span>{" "} <span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit {selectedUser.client.lastVisit
? new Date( ? new Date(
selectedUser.client.lastVisit, selectedUser.client.lastVisit,
).toLocaleDateString() ).toLocaleDateString()
: "Never"} : "Never"}
</p> </p>
</div> </div>
@ -513,9 +655,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>

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,21 @@
import { User as SharedUser, Client, FitnessProfile, Attendance, Recommendation, FitnessGoal } from "@fitai/shared"; import {
User as SharedUser,
Client,
FitnessProfile,
Attendance,
Recommendation,
FitnessGoal,
} from "@fitai/shared";
// Database Entity Types // Database Entity Types
export interface User extends SharedUser { export interface User extends SharedUser {
// Explicitly include gymId so server imports can rely on it even if shared types lag behind build
gymId?: string;
password: string; password: string;
} }
export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal }; export type { Client, FitnessProfile, Attendance, Recommendation, FitnessGoal };
// Database Interface - allows us to swap implementations // Database Interface - allows us to swap implementations
export interface IDatabase { export interface IDatabase {
// Connection management // Connection management
@ -58,7 +66,10 @@ export interface IDatabase {
// Recommendation operations // Recommendation operations
createRecommendation( createRecommendation(
recommendation: Omit<Recommendation, "createdAt" | "approvedAt" | "approvedBy">, recommendation: Omit<
Recommendation,
"createdAt" | "approvedAt" | "approvedBy"
>,
): Promise<Recommendation>; ): Promise<Recommendation>;
getRecommendationsByUserId(userId: string): Promise<Recommendation[]>; getRecommendationsByUserId(userId: string): Promise<Recommendation[]>;
getAllRecommendations(): Promise<Recommendation[]>; getAllRecommendations(): Promise<Recommendation[]>;
@ -70,16 +81,22 @@ export interface IDatabase {
// Fitness Goals operations // Fitness Goals operations
createFitnessGoal( createFitnessGoal(
goal: Omit<FitnessGoal, "createdAt" | "updatedAt"> goal: Omit<FitnessGoal, "createdAt" | "updatedAt">,
): Promise<FitnessGoal>; ): Promise<FitnessGoal>;
getFitnessGoalById(id: string): Promise<FitnessGoal | null>; getFitnessGoalById(id: string): Promise<FitnessGoal | null>;
getFitnessGoalsByUserId(userId: string, status?: string): Promise<FitnessGoal[]>; getFitnessGoalsByUserId(
userId: string,
status?: string,
): Promise<FitnessGoal[]>;
updateFitnessGoal( updateFitnessGoal(
id: string, id: string,
updates: Partial<FitnessGoal> updates: Partial<FitnessGoal>,
): Promise<FitnessGoal | null>; ): Promise<FitnessGoal | null>;
deleteFitnessGoal(id: string): Promise<boolean>; deleteFitnessGoal(id: string): Promise<boolean>;
updateGoalProgress(id: string, currentValue: number): Promise<FitnessGoal | null>; updateGoalProgress(
id: string,
currentValue: number,
): Promise<FitnessGoal | null>;
completeGoal(id: string): Promise<FitnessGoal | null>; completeGoal(id: string): Promise<FitnessGoal | null>;
// Dashboard operations // Dashboard operations

View File

@ -1,62 +1,72 @@
import { currentUser } from '@clerk/nextjs/server' import { currentUser } from "@clerk/nextjs/server";
import { IDatabase } from './database/types' import type { IDatabase, User } from "./database/types";
export async function ensureUserSynced(userId: string, db: IDatabase) { export async function ensureUserSynced(
const existingUser = await db.getUserById(userId) userId: string,
if (existingUser) return existingUser db: IDatabase,
): Promise<User | null> {
const existingUser = await db.getUserById(userId);
if (existingUser) return existingUser;
console.log('User not found in DB by ID, checking Clerk:', userId) console.log("User not found in DB by ID, checking Clerk:", userId);
const clerkUser = await currentUser() const clerkUser = await currentUser();
if (!clerkUser || clerkUser.id !== userId) { if (!clerkUser || clerkUser.id !== userId) {
// If we can't get the user from Clerk (e.g. running locally without full auth sync), // If we can't get the user from Clerk (e.g. running locally without full auth sync),
// we might want to fail gracefully or throw. // we might want to fail gracefully or throw.
// For now, throw to be safe. // For now, throw to be safe.
throw new Error('Could not fetch Clerk user details') throw new Error("Could not fetch Clerk user details");
} }
const email = clerkUser.emailAddresses[0]?.emailAddress const email = clerkUser.emailAddresses[0]?.emailAddress;
if (!email) throw new Error('User has no email') if (!email) throw new Error("User has no email");
// Check if user exists by email (e.g. seeded user) // Check if user exists by email (e.g. seeded user)
const existingByEmail = await db.getUserByEmail(email) const existingByEmail = await db.getUserByEmail(email);
if (existingByEmail) { if (existingByEmail) {
console.log('User found by email but ID mismatch. Migrating ID...', { console.log("User found by email but ID mismatch. Migrating ID...", {
oldId: existingByEmail.id, oldId: existingByEmail.id,
newId: userId newId: userId,
}) });
// Update the ID to match Clerk ID // Update the ID to match Clerk ID
// We need to do this manually via SQL because IDatabase interface might not expose a direct ID update method easily // We need to do this manually via SQL because IDatabase interface might not expose a direct ID update method easily
// But we can use a raw query if we had access, or add a method. // But we can use a raw query if we had access, or add a method.
// Since we don't have direct access to `db.db` here (it's hidden behind IDatabase), // Since we don't have direct access to `db.db` here (it's hidden behind IDatabase),
// we should add a method to IDatabase or use a workaround. // we should add a method to IDatabase or use a workaround.
// Actually, `SQLiteDatabase` is what we have at runtime. // Actually, `SQLiteDatabase` is what we have at runtime.
// Let's assume we can cast it or add `updateUserId` to the interface. // Let's assume we can cast it or add `updateUserId` to the interface.
// For now, let's try to update it using `updateUser` but `updateUser` usually updates fields based on ID. // For now, let's try to update it using `updateUser` but `updateUser` usually updates fields based on ID.
// We can't update the ID itself using `updateUser(id, { id: newId })` because `updateUser` implementation filters out `id` from updates. // We can't update the ID itself using `updateUser(id, { id: newId })` because `updateUser` implementation filters out `id` from updates.
// We need to add a method to migrate user ID in IDatabase. // We need to add a method to migrate user ID in IDatabase.
// Or, strictly for this prototype, we can delete the old user and create a new one (DATA LOSS RISK). // Or, strictly for this prototype, we can delete the old user and create a new one (DATA LOSS RISK).
// But since it's a seeded super admin with no data, it's fine. // But since it's a seeded super admin with no data, it's fine.
// However, if they had clients, we'd lose the link. // However, if they had clients, we'd lose the link.
// Let's add `migrateUserId(oldId, newId)` to IDatabase. // Let's add `migrateUserId(oldId, newId)` to IDatabase.
await db.migrateUserId(existingByEmail.id, userId) await db.migrateUserId(existingByEmail.id, userId);
return db.getUserById(userId) return db.getUserById(userId);
} }
console.log('Creating new user from Clerk data:', userId) console.log("Creating new user from Clerk data:", userId);
const user = await db.createUser({ const user = await db.createUser({
id: userId, id: userId,
email, email,
firstName: clerkUser.firstName || '', firstName: clerkUser.firstName || "",
lastName: clerkUser.lastName || '', lastName: clerkUser.lastName || "",
password: '', // Managed by Clerk password: "", // Managed by Clerk
phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined, phone: clerkUser.phoneNumbers[0]?.phoneNumber || undefined,
role: (clerkUser.publicMetadata.role as 'admin' | 'client' | 'superAdmin') || 'client' gymId: (clerkUser.publicMetadata.gymId as string | undefined) || undefined,
}) role: ((): any => {
const r = clerkUser.publicMetadata.role as string | undefined;
return r &&
["superAdmin", "admin", "trainer", "client", "generalUser"].includes(r)
? r
: "client";
})(),
});
return user return user;
} }

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", { text: "Cancel", style: "cancel" },
"Are you sure you want to sign out?", { text: "Sign Out", style: "destructive", onPress: handleSignOut },
[ ]);
{ text: "Cancel", style: "cancel" }, };
{ text: "Sign Out", style: "destructive", onPress: handleSignOut },
] // Gym selection state and handlers
); const { getToken } = useAuth();
const [gyms, setGyms] = useState<
Array<{ id: string; name: string; location?: string }>
>([]);
const [gymsLoading, setGymsLoading] = useState(false);
const [selectedGymId, setSelectedGymId] = useState<string | null>(null);
const loadGyms = async () => {
try {
setGymsLoading(true);
const token = await getToken();
const res = await fetch("/api/gyms", {
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
});
const data = await res.json();
setGyms(Array.isArray(data) ? data : []);
} catch (err) {
console.error("Failed to fetch gyms:", err);
} finally {
setGymsLoading(false);
}
};
const handleApplyGym = async () => {
try {
const token = await getToken();
await fetch("/api/users/gym", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ gymId: selectedGymId }),
});
Alert.alert(
"Success",
selectedGymId ? "Gym selected successfully" : "Proceeding without gym",
);
} catch (err) {
console.error("Failed to update gym selection:", err);
Alert.alert("Error", "Failed to update gym selection");
}
}; };
return ( return (
@ -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://a4db649a0973.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

@ -1,15 +1,12 @@
import Database from 'better-sqlite3' import Database from "better-sqlite3";
import { drizzle } from 'drizzle-orm/better-sqlite3' import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from './schema' import * as schema from "./schema";
// Configurable database path with intelligent defaults // Configurable database path with intelligent defaults
const dbPath = process.env.DATABASE_URL || const dbPath = "./data/fitai.db";
(process.env.NODE_ENV === 'production'
? './data/fitai.db'
: '../../apps/admin/data/fitai.db')
const sqlite = new Database(dbPath) const sqlite = new Database(dbPath);
export const db = drizzle(sqlite, { schema }) export const db = drizzle(sqlite, { schema });
export * from './schema' export * from "./schema";
export { eq, and, or, desc, asc, sql } from 'drizzle-orm' export { eq, and, or, desc, asc, sql } from "drizzle-orm";

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";
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;
} }