working on onboarding flow
This commit is contained in:
1 parent
868eaa5e3d
commit
d3a36b6103
19 files changed
+2062
-393
No files matched your search
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,59 @@
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getDatabase } from '@/lib/database'
|
||||
import { ensureUserSynced } from '@/lib/sync-user'
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return new NextResponse("Unauthorized", { status: 401 });
|
||||
|
||||
const db = await getDatabase()
|
||||
const user = await ensureUserSynced(userId, db)
|
||||
const db = await getDatabase();
|
||||
const user = await ensureUserSynced(userId, db);
|
||||
|
||||
if (!user || (user.role !== 'admin' && user.role !== 'superAdmin')) {
|
||||
return new NextResponse('Forbidden', { status: 403 })
|
||||
}
|
||||
|
||||
const stats = await db.getDashboardStats()
|
||||
|
||||
return NextResponse.json(stats)
|
||||
} catch (error) {
|
||||
console.error('Dashboard stats error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
if (user.role === "admin" && !user.gymId) {
|
||||
return new NextResponse("Admin gymId not set", { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const searchParams = url.searchParams;
|
||||
|
||||
let targetGymId: string | null = null;
|
||||
if (user.role === "admin") {
|
||||
targetGymId = user.gymId ?? null;
|
||||
} else if (user.role === "superAdmin") {
|
||||
targetGymId = searchParams.get("gymId");
|
||||
}
|
||||
|
||||
const allUsers = await db.getAllUsers();
|
||||
const allClients = await db.getAllClients();
|
||||
|
||||
const usersById = new Map(allUsers.map((u) => [u.id, u]));
|
||||
const filteredUsers = targetGymId
|
||||
? allUsers.filter((u) => u.gymId === targetGymId)
|
||||
: allUsers;
|
||||
const filteredClients = targetGymId
|
||||
? allClients.filter((c) => {
|
||||
const u = usersById.get(c.userId);
|
||||
return u?.gymId === targetGymId;
|
||||
})
|
||||
: allClients;
|
||||
|
||||
const stats = {
|
||||
totalUsers: filteredUsers.length,
|
||||
activeClients: filteredClients.filter(
|
||||
(c) => c.membershipStatus === "active",
|
||||
).length,
|
||||
totalRevenue: 0,
|
||||
revenueGrowth: 0,
|
||||
};
|
||||
|
||||
return NextResponse.json(stats);
|
||||
} catch (error) {
|
||||
console.error("Dashboard stats error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
|
||||
/**
|
||||
* GET /api/admin/trainers
|
||||
*
|
||||
* Admin:
|
||||
* - Lists trainers scoped to the admin's gym (requires admin.gymId).
|
||||
*
|
||||
* SuperAdmin:
|
||||
* - Optional query param ?gymId=<id> to filter trainers by a specific gym.
|
||||
* - If no gymId provided, returns all trainers across all gyms.
|
||||
*
|
||||
* Response: Array of trainer users with minimal fields for listing
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
const user = await ensureUserSynced(userId, db);
|
||||
|
||||
if (!user || (user.role !== "admin" && user.role !== "superAdmin")) {
|
||||
return new NextResponse("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const requestedGymId = url.searchParams.get("gymId");
|
||||
|
||||
// Admins must have a gymId; scope to their gym
|
||||
let targetGymId: string | null = null;
|
||||
if (user.role === "admin") {
|
||||
if (!user.gymId) {
|
||||
return new NextResponse("Admin gymId not set", { status: 400 });
|
||||
}
|
||||
targetGymId = user.gymId;
|
||||
} else if (user.role === "superAdmin") {
|
||||
targetGymId = requestedGymId;
|
||||
}
|
||||
|
||||
// Fetch all users and filter to trainers
|
||||
const allUsers = await db.getAllUsers();
|
||||
let trainers = allUsers.filter((u) => u.role === "trainer");
|
||||
|
||||
// Scope by gym when required/provided
|
||||
if (targetGymId) {
|
||||
trainers = trainers.filter((t) => t.gymId === targetGymId);
|
||||
}
|
||||
|
||||
// Minimal payload suitable for listing
|
||||
const payload = trainers.map((t) => ({
|
||||
id: t.id,
|
||||
email: t.email,
|
||||
firstName: t.firstName,
|
||||
lastName: t.lastName,
|
||||
gymId: t.gymId ?? null,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json(payload);
|
||||
} catch (error) {
|
||||
console.error("GET /api/admin/trainers error:", error);
|
||||
return new NextResponse("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth, clerkClient } from "@clerk/nextjs/server";
|
||||
|
||||
/**
|
||||
* POST /api/invitations
|
||||
*
|
||||
* Create a Clerk-managed invitation and store role/gym context in publicMetadata.
|
||||
* This endpoint does not implement invitation acceptance; Clerk’s acceptance link flow and webhooks will handle that.
|
||||
*
|
||||
* Body: {
|
||||
* inviteeEmail: string,
|
||||
* roleAssigned: 'trainer' | 'client' | 'admin'
|
||||
* }
|
||||
*
|
||||
* Rules:
|
||||
* - admin: can invite trainer or client; the gymId is taken from inviter's publicMetadata or user record.
|
||||
* - trainer: can invite client; the gymId is taken from inviter's publicMetadata or user record.
|
||||
* - superAdmin: can invite admin; requires `gymId` in body, or falls back to inviter's `gymId` if present.
|
||||
*
|
||||
* Returns Clerk invitation payload.
|
||||
*/
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return new NextResponse("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const inviteeEmail = String(body.inviteeEmail ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const roleAssigned = String(body.roleAssigned ?? "").trim() as
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "admin";
|
||||
let requestedGymId: string | null = body.gymId ? String(body.gymId) : null;
|
||||
|
||||
if (!inviteeEmail || !roleAssigned) {
|
||||
return NextResponse.json(
|
||||
{ error: "inviteeEmail and roleAssigned are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch inviter user from Clerk
|
||||
const client = await clerkClient();
|
||||
const inviter = await client.users.getUser(userId);
|
||||
const inviterRole =
|
||||
(inviter.publicMetadata?.role as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser") ?? "client";
|
||||
const inviterGymId =
|
||||
(inviter.publicMetadata?.gymId as string | undefined) ?? undefined;
|
||||
|
||||
// Enforce role-based rules and resolve target gymId for the invitation
|
||||
let gymIdForInvite: string | null = null;
|
||||
switch (inviterRole) {
|
||||
case "admin": {
|
||||
if (roleAssigned !== "trainer" && roleAssigned !== "client") {
|
||||
return NextResponse.json(
|
||||
{ error: "Admin can only invite trainer or client" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (!inviterGymId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Inviter admin must be assigned to a gym" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
gymIdForInvite = inviterGymId;
|
||||
break;
|
||||
}
|
||||
case "trainer": {
|
||||
if (roleAssigned !== "client") {
|
||||
return NextResponse.json(
|
||||
{ error: "Trainer can only invite client" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
if (!inviterGymId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Inviter trainer must be assigned to a gym" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
gymIdForInvite = inviterGymId;
|
||||
break;
|
||||
}
|
||||
case "superAdmin": {
|
||||
if (
|
||||
roleAssigned !== "admin" &&
|
||||
roleAssigned !== "trainer" &&
|
||||
roleAssigned !== "client"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid roleAssigned for SuperAdmin" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
// Prefer explicitly provided gymId, otherwise fall back to inviter's gymId if present
|
||||
gymIdForInvite = requestedGymId || inviterGymId || null;
|
||||
if (!gymIdForInvite) {
|
||||
return NextResponse.json(
|
||||
{ error: "gymId is required for SuperAdmin when inviting" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return NextResponse.json(
|
||||
{ error: "Inviter role not permitted to create invitations" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create Clerk invitation with metadata needed by webhook to assign role & gym
|
||||
// reuse existing Clerk client instance
|
||||
const invitation = await client.invitations.createInvitation({
|
||||
emailAddress: inviteeEmail,
|
||||
publicMetadata: {
|
||||
roleAssigned,
|
||||
gymId: gymIdForInvite,
|
||||
inviterUserId: inviter.id,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(invitation, { status: 201 });
|
||||
} catch (error: any) {
|
||||
// Surface Clerk errors where possible
|
||||
const message =
|
||||
error?.errors?.[0]?.message ||
|
||||
error?.message ||
|
||||
"Failed to create invitation";
|
||||
console.error("POST /api/invitations error:", error);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { auth } from '@clerk/nextjs/server'
|
||||
import { db, users as usersTable, gyms as gymsTable, eq } from '@fitai/database'
|
||||
|
||||
/**
|
||||
* PATCH /api/users/gym
|
||||
* Body: { gymId: string | null }
|
||||
* - Updates the current authenticated user's gym selection.
|
||||
* - gymId can be null to proceed without a gym.
|
||||
* - If gymId is provided, it must exist and be active.
|
||||
*/
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const { userId } = await auth()
|
||||
if (!userId) return new NextResponse('Unauthorized', { status: 401 })
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
if (!body || typeof body !== 'object' || !('gymId' in body)) {
|
||||
return NextResponse.json({ error: 'gymId is required in body (can be null)' }, { status: 400 })
|
||||
}
|
||||
|
||||
const gymId = body.gymId === null ? null : String(body.gymId)
|
||||
|
||||
// Ensure user exists
|
||||
const user = await db.select().from(usersTable).where(eq(usersTable.id, userId)).get()
|
||||
if (!user) return new NextResponse('User not found', { status: 404 })
|
||||
|
||||
// Validate gym when provided
|
||||
if (gymId) {
|
||||
const gym = await db.select().from(gymsTable).where(eq(gymsTable.id, gymId)).get()
|
||||
if (!gym) {
|
||||
return NextResponse.json({ error: 'Gym not found' }, { status: 404 })
|
||||
}
|
||||
if (gym.status !== 'active') {
|
||||
return NextResponse.json({ error: 'Gym is not active' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Update user's gym selection
|
||||
await db
|
||||
.update(usersTable)
|
||||
.set({
|
||||
gymId: gymId ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(usersTable.id, userId))
|
||||
.run()
|
||||
|
||||
const updated = await db.select().from(usersTable).where(eq(usersTable.id, userId)).get()
|
||||
return NextResponse.json(updated)
|
||||
} catch (error) {
|
||||
console.error('PATCH /users/gym error:', error)
|
||||
return new NextResponse('Internal Server Error', { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -45,14 +45,14 @@ export async function GET(request: NextRequest) {
|
||||
const weekAgo = new Date();
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
checkInsThisWeek = attendanceHistory.filter(
|
||||
a => new Date(a.checkInTime) >= weekAgo
|
||||
(a) => new Date(a.checkInTime) >= weekAgo,
|
||||
).length;
|
||||
|
||||
// Calculate check-ins in last 30 days
|
||||
const monthAgo = new Date();
|
||||
monthAgo.setDate(monthAgo.getDate() - 30);
|
||||
checkInsThisMonth = attendanceHistory.filter(
|
||||
a => new Date(a.checkInTime) >= monthAgo
|
||||
(a) => new Date(a.checkInTime) >= monthAgo,
|
||||
).length;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export async function GET(request: NextRequest) {
|
||||
checkInTime,
|
||||
lastCheckInTime,
|
||||
checkInsThisWeek,
|
||||
checkInsThisMonth
|
||||
checkInsThisMonth,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -88,12 +88,12 @@ export async function POST(request: NextRequest) {
|
||||
const db = await getDatabase();
|
||||
|
||||
// Get current user to check role
|
||||
// Note: In a real app, we'd map Clerk ID to our DB ID.
|
||||
// Note: In a real app, we'd map Clerk ID to our DB ID.
|
||||
// For now, we'll assume we can find the user by some means or trust the Clerk metadata if we synced it.
|
||||
// Since we don't have Clerk ID in our local DB users table yet (we only have our own ID),
|
||||
// we might need to rely on the user being synced.
|
||||
// Let's assume the user calling this API is already in our DB.
|
||||
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
|
||||
// For the prototype, we'll fetch the user by matching the Clerk ID if we stored it,
|
||||
// OR we'll assume the first user is Super Admin if no users exist?
|
||||
// Actually, we should look up the user by email if we can't by ID, or add a clerkId column.
|
||||
// For this step, let's assume we can get the user.
|
||||
@@ -105,7 +105,10 @@ export async function POST(request: NextRequest) {
|
||||
const currentUser = await db.getUserById(clerkUserId);
|
||||
|
||||
if (!currentUser) {
|
||||
return NextResponse.json({ error: "Current user not found in database" }, { status: 403 });
|
||||
return NextResponse.json(
|
||||
{ error: "Current user not found in database" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
@@ -123,14 +126,14 @@ export async function POST(request: NextRequest) {
|
||||
superAdmin: ["admin", "trainer", "client"],
|
||||
admin: ["trainer", "client"],
|
||||
trainer: ["client"],
|
||||
client: []
|
||||
client: [],
|
||||
};
|
||||
|
||||
const userRole = currentUser.role as keyof typeof allowed;
|
||||
if (!allowed[userRole] || !allowed[userRole].includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: `You are not authorized to create a ${role}` },
|
||||
{ status: 403 }
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -152,20 +155,24 @@ export async function POST(request: NextRequest) {
|
||||
publicMetadata: {
|
||||
role,
|
||||
},
|
||||
ignoreExisting: true // Don't fail if invite exists
|
||||
ignoreExisting: true, // Don't fail if invite exists
|
||||
});
|
||||
} catch (clerkError: any) {
|
||||
console.error("Clerk invitation error:", clerkError);
|
||||
// If user already exists in Clerk, we might want to handle it.
|
||||
// But for now, let's proceed to create local record if invite sent or if they exist.
|
||||
if (clerkError.errors?.[0]?.code === 'form_identifier_exists') {
|
||||
if (clerkError.errors?.[0]?.code === "form_identifier_exists") {
|
||||
return NextResponse.json(
|
||||
{ error: "User already exists in Clerk system" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send invitation: " + (clerkError.message || "Unknown error") },
|
||||
{
|
||||
error:
|
||||
"Failed to send invitation: " +
|
||||
(clerkError.message || "Unknown error"),
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
@@ -182,16 +189,19 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
// If creating a client, create the client record too
|
||||
if (role === 'client') {
|
||||
if (role === "client") {
|
||||
await db.createClient({
|
||||
userId: newUserId.id,
|
||||
membershipType: 'basic',
|
||||
membershipStatus: 'active',
|
||||
joinDate: new Date()
|
||||
membershipType: "basic",
|
||||
membershipStatus: "active",
|
||||
joinDate: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ userId: newUserId.id, message: "Invitation sent" }, { status: 201 });
|
||||
return NextResponse.json(
|
||||
{ userId: newUserId.id, message: "Invitation sent" },
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Create user error:", error);
|
||||
return NextResponse.json(
|
||||
@@ -205,7 +215,7 @@ export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const db = await getDatabase();
|
||||
const body = await request.json();
|
||||
const { id, email, firstName, lastName, role, phone } = body;
|
||||
const { id, email, firstName, lastName, role, phone, gymId } = body;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
@@ -214,12 +224,43 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get existing user
|
||||
// Authenticate requester
|
||||
const { userId: requesterId } = await auth();
|
||||
if (!requesterId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch requester and target user
|
||||
const requester = await db.getUserById(requesterId);
|
||||
if (!requester) {
|
||||
return NextResponse.json(
|
||||
{ error: "Requester not found" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const existingUser = await db.getUserById(id);
|
||||
if (!existingUser) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Authorization: determine allowed role changes
|
||||
const requesterRole = requester.role;
|
||||
const allowedByRole: Record<string, string[]> = {
|
||||
superAdmin: ["superAdmin", "admin", "trainer", "client", "generalUser"],
|
||||
admin: ["admin", "trainer", "client", "generalUser"],
|
||||
trainer: [], // trainers cannot change roles
|
||||
client: [], // clients cannot change roles
|
||||
generalUser: [], // general users cannot change roles
|
||||
};
|
||||
|
||||
if (role && !allowedByRole[requesterRole]?.includes(role)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Not authorized to assign role '${role}'` },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if email is being changed and if it's already taken
|
||||
if (email && email !== existingUser.email) {
|
||||
const userWithEmail = await db.getUserByEmail(email);
|
||||
@@ -231,14 +272,39 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
// Update Clerk publicMetadata (role/gymId) to propagate via webhook
|
||||
// Note: Only update metadata when a change is requested
|
||||
try {
|
||||
const client = await clerkClient();
|
||||
const publicMetadata: Record<string, unknown> = {};
|
||||
|
||||
if (role) {
|
||||
publicMetadata.role = role;
|
||||
}
|
||||
if (gymId !== undefined) {
|
||||
publicMetadata.gymId = gymId === null ? null : String(gymId);
|
||||
}
|
||||
|
||||
if (Object.keys(publicMetadata).length > 0) {
|
||||
await client.users.updateUser(id, { publicMetadata });
|
||||
}
|
||||
} catch (clerkErr: any) {
|
||||
console.error("Clerk metadata update error:", clerkErr);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update role/gym in identity provider" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update local DB for immediate UI feedback (webhook will also sync)
|
||||
await db.updateUser(id, {
|
||||
email: email || existingUser.email,
|
||||
firstName: firstName || existingUser.firstName,
|
||||
lastName: lastName || existingUser.lastName,
|
||||
role: role || existingUser.role,
|
||||
email: email ?? existingUser.email,
|
||||
firstName: firstName ?? existingUser.firstName,
|
||||
lastName: lastName ?? existingUser.lastName,
|
||||
role: role ?? existingUser.role,
|
||||
phone: phone !== undefined ? phone : existingUser.phone,
|
||||
});
|
||||
gymId: gymId !== undefined ? gymId : existingUser.gymId,
|
||||
} as any);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -78,15 +78,31 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine role from metadata or default to 'client'
|
||||
// Determine role & gym from metadata
|
||||
const role =
|
||||
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
|
||||
(public_metadata?.role as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser") || "client";
|
||||
const gymId = (public_metadata?.gymId as string | null) ?? null;
|
||||
const inviterUserId =
|
||||
(public_metadata?.inviterUserId as string | undefined) ?? undefined;
|
||||
const roleAssigned =
|
||||
(public_metadata?.roleAssigned as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser"
|
||||
| undefined) ?? role;
|
||||
|
||||
// Insert user into database with Clerk's user ID
|
||||
const now = new Date().toISOString();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO users (id, email, firstName, lastName, password, phone, role, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, email, first_name, last_name, password, phone, role, gym_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
@@ -97,11 +113,57 @@ export async function POST(req: Request) {
|
||||
"", // Clerk handles authentication
|
||||
null, // phone
|
||||
role,
|
||||
gymId,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
console.log(`✅ User ${id} created in database`);
|
||||
// If this is a client invited by a trainer, create trainer-client link
|
||||
if (roleAssigned === "client" && inviterUserId && gymId) {
|
||||
const inviterRow = db
|
||||
.prepare("SELECT role FROM users WHERE id = ?")
|
||||
.get(inviterUserId) as { role?: string } | undefined;
|
||||
|
||||
if (inviterRow?.role === "trainer") {
|
||||
const linkId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
||||
const linkStmt = db.prepare(`
|
||||
INSERT INTO trainer_clients (id, trainer_user_id, client_user_id, gym_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
linkStmt.run(
|
||||
linkId,
|
||||
inviterUserId,
|
||||
id,
|
||||
gymId,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is a trainer without a gymId but has an inviter, inherit inviter's gymId
|
||||
if (
|
||||
(roleAssigned === "trainer" || role === "trainer") &&
|
||||
!gymId &&
|
||||
inviterUserId
|
||||
) {
|
||||
const inviterGymRow = db
|
||||
.prepare("SELECT gymId FROM users WHERE id = ?")
|
||||
.get(inviterUserId) as { gymId?: string } | undefined;
|
||||
|
||||
if (inviterGymRow?.gym_id) {
|
||||
const inheritStmt = db.prepare(`
|
||||
UPDATE users
|
||||
SET gym_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
|
||||
gymId = inviterGymRow.gym_id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ User ${id} created in database (role=${role}, gymId=${gymId ?? "null"})`,
|
||||
);
|
||||
db.close();
|
||||
break;
|
||||
}
|
||||
@@ -124,15 +186,21 @@ export async function POST(req: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine role from metadata
|
||||
// Determine role & gym from metadata
|
||||
const role =
|
||||
(public_metadata?.role as "admin" | "trainer" | "client") || "client";
|
||||
(public_metadata?.role as
|
||||
| "superAdmin"
|
||||
| "admin"
|
||||
| "trainer"
|
||||
| "client"
|
||||
| "generalUser") || "client";
|
||||
const gymId = (public_metadata?.gymId as string | null) ?? null;
|
||||
|
||||
// Update user in database
|
||||
const now = new Date().toISOString();
|
||||
const stmt = db.prepare(`
|
||||
UPDATE users
|
||||
SET email = ?, firstName = ?, lastName = ?, role = ?, updatedAt = ?
|
||||
SET email = ?, first_name = ?, last_name = ?, role = ?, gym_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
@@ -141,10 +209,33 @@ export async function POST(req: Request) {
|
||||
first_name || "",
|
||||
last_name || "",
|
||||
role,
|
||||
gymId,
|
||||
now,
|
||||
id,
|
||||
);
|
||||
|
||||
// If user is a trainer and gymId is missing, attempt to inherit from inviter when available
|
||||
if (
|
||||
role === "trainer" &&
|
||||
!gymId &&
|
||||
evt.data.public_metadata?.inviterUserId
|
||||
) {
|
||||
const inviterUserId = String(evt.data.public_metadata.inviterUserId);
|
||||
const inviterGymRow = db
|
||||
.prepare("SELECT gym_id FROM users WHERE id = ?")
|
||||
.get(inviterUserId) as { gym_id?: string } | undefined;
|
||||
|
||||
if (inviterGymRow?.gymId) {
|
||||
const inheritStmt = db.prepare(`
|
||||
UPDATE users
|
||||
SET gym_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
inheritStmt.run(inviterGymRow.gym_id, new Date().toISOString(), id);
|
||||
gymId = inviterGymRow.gym_id;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ User ${id} updated in database`);
|
||||
db.close();
|
||||
break;
|
||||
|
||||
Reference in new issue
Block a user