Compare commits

...
9 Commits
Author SHA1 Message Date
dimitar 1be7dc2858 user id -> user name
in attendace
2026-03-19 01:37:10 +01:00
dimitar 36f52b42c6 ash 2026-03-19 01:32:18 +01:00
dimitar 3e30fae173 attendace, user stats 2026-03-19 01:29:09 +01:00
dimitar d6a77fcd23 various fixes
raw sql to drizzle
2026-03-19 00:57:04 +01:00
dimitar ffd3aabc55 delete gym working 2026-03-19 00:44:39 +01:00
dimitar 8cac57ed67 db updated 2026-03-19 00:39:23 +01:00
dimitar d4fae890bb invitation flow finished 2026-03-18 23:58:14 +01:00
dimitar 0817e8e72b invitation flow basis 2026-03-18 23:08:55 +01:00
dimitar b1f01208fa admin role enhancment
basic invitation flow
2026-03-18 13:56:51 +01:00
21 changed files with 722 additions and 99 deletions

No files matched your search

+4
View File
@@ -14,6 +14,10 @@ RESEND_API_KEY=re_your_resend_api_key_here
EMAIL_FROM=FitAI <noreply@yourdomain.com> EMAIL_FROM=FitAI <noreply@yourdomain.com>
EMAIL_REPLY_TO=support@yourdomain.com EMAIL_REPLY_TO=support@yourdomain.com
# Admin App URL (for invitation redirects)
# Set to your production URL in production
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Database (optional - defaults to ./fitai.db) # Database (optional - defaults to ./fitai.db)
DATABASE_PATH=./fitai.db DATABASE_PATH=./fitai.db
Binary file not shown.
@@ -34,7 +34,33 @@ export async function GET(req: NextRequest) {
? await getAttendanceByGym(targetGymId) ? await getAttendanceByGym(targetGymId)
: await db.getAllAttendance(); : await db.getAllAttendance();
return successResponse({ records: attendance }); // Get all users to enrich attendance with user names
const allUsers = await db.getAllUsers();
const userMap = new Map(
allUsers.map((u) => [
u.id,
{
firstName: u.firstName,
lastName: u.lastName,
email: u.email,
},
]),
);
// Enrich attendance records with user information
const enrichedAttendance = attendance.map((record) => {
const userInfo = userMap.get(record.userId);
return {
...record,
userName: userInfo
? `${userInfo.firstName} ${userInfo.lastName}`.trim() ||
userInfo.email
: record.userId,
userEmail: userInfo?.email,
};
});
return successResponse({ records: enrichedAttendance });
} catch (error) { } catch (error) {
console.error("Admin attendance error:", error); console.error("Admin attendance error:", error);
return new NextResponse("Internal Server Error", { status: 500 }); return new NextResponse("Internal Server Error", { status: 500 });
+24 -14
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database"; import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database"; import { db, users as usersTable, gyms as gymsTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user"; import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger"; import log from "@/lib/logger";
@@ -30,7 +30,7 @@ export async function DELETE(
const { userId } = await auth(); const { userId } = await auth();
if (!userId) { if (!userId) {
return new NextResponse("Unauthorized", { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
// Ensure user is synced // Ensure user is synced
@@ -60,23 +60,30 @@ export async function DELETE(
// Only superAdmin can delete gyms // Only superAdmin can delete gyms
if (!currentUser || currentUser.role !== "superAdmin") { if (!currentUser || currentUser.role !== "superAdmin") {
return new NextResponse("Forbidden - Only superAdmin can delete gyms", { return NextResponse.json(
status: 403, { error: "Forbidden - Only superAdmin can delete gyms" },
}); { status: 403 },
);
} }
await ensureGymsTable(); await ensureGymsTable();
// Check if gym exists // Check if gym exists using Drizzle ORM
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`); const existingGym = await db
if (gymRows.length === 0) { .select()
return new NextResponse("Gym not found", { status: 404 }); .from(gymsTable)
.where(eq(gymsTable.id, gymId))
.get();
if (!existingGym) {
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
} }
// Soft delete - mark as inactive // Soft delete - mark as inactive using Drizzle ORM
await db.run( await db
sql`UPDATE gyms SET status = 'inactive', updated_at = ${Date.now()} WHERE id = ${gymId}`, .update(gymsTable)
); .set({ status: "inactive", updatedAt: new Date() })
.where(eq(gymsTable.id, gymId));
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
@@ -84,6 +91,9 @@ export async function DELETE(
}); });
} catch (error) { } catch (error) {
log.error("Failed to delete gym", error); log.error("Failed to delete gym", error);
return new NextResponse("Internal Server Error", { status: 500 }); return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
} }
} }
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { eq, sql } from "@fitai/database"; import { eq, sql } from "@fitai/database";
import { db } from "@fitai/database"; import { db, gyms as gymsTable } from "@fitai/database";
import log from "@/lib/logger"; import log from "@/lib/logger";
async function ensureGymsTable() { async function ensureGymsTable() {
@@ -27,12 +27,16 @@ export async function GET(
const { id: gymId } = await params; const { id: gymId } = await params;
await ensureGymsTable(); await ensureGymsTable();
// Get gym info // Get gym info using Drizzle ORM
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`); const gym = await db
if (gymRows.length === 0) { .select()
return new NextResponse("Gym not found", { status: 404 }); .from(gymsTable)
.where(eq(gymsTable.id, gymId))
.get();
if (!gym) {
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
} }
const gym = gymRows[0];
// Get user counts // Get user counts
const usersResult = await db.all( const usersResult = await db.all(
@@ -76,7 +80,8 @@ export async function GET(
} }
// Get recent activity (attendance in last 30 days) // Get recent activity (attendance in last 30 days)
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; // Database stores timestamps in seconds, so convert milliseconds to seconds
const thirtyDaysAgo = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
const attendanceResult = (await db.all( const attendanceResult = (await db.all(
sql`SELECT COUNT(*) as count FROM attendance sql`SELECT COUNT(*) as count FROM attendance
WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId}) WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId})
@@ -100,6 +105,9 @@ export async function GET(
return NextResponse.json({ gym, stats }); return NextResponse.json({ gym, stats });
} catch (error) { } catch (error) {
log.error("Failed to get gym stats", error); log.error("Failed to get gym stats", error);
return new NextResponse("Internal Server Error", { status: 500 }); return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
} }
} }
+27 -13
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server"; import { auth } from "@clerk/nextjs/server";
import { eq, sql } from "@fitai/database"; import { eq, sql } from "@fitai/database";
import { db, users as usersTable } from "@fitai/database"; import { db, users as usersTable, gyms as gymsTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user"; import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger"; import log from "@/lib/logger";
@@ -24,9 +24,12 @@ async function ensureGymsTable() {
export async function GET() { export async function GET() {
try { try {
await ensureGymsTable(); await ensureGymsTable();
const rows = await db.all( const rows = await db
sql`SELECT * FROM gyms WHERE status = 'active' ORDER BY created_at DESC`, .select()
); .from(gymsTable)
.where(eq(gymsTable.status, "active"))
.orderBy(sql`created_at DESC`)
.all();
return NextResponse.json(rows); return NextResponse.json(rows);
} catch (error) { } catch (error) {
@@ -146,19 +149,30 @@ export async function POST(req: Request) {
} }
const id = generateId(); const id = generateId();
const nowTs = Date.now(); const nowTs = new Date();
await db.run( // Use Drizzle's insert method instead of raw SQL
sql`INSERT INTO gyms (id, name, location, status, admin_user_id, created_at, updated_at) await db.insert(gymsTable).values({
VALUES (${id}, ${name}, ${location ?? null}, 'active', ${adminUserId!}, ${nowTs}, ${nowTs})`, id,
); name,
location: location ?? null,
status: "active",
adminUserId: adminUserId!,
createdAt: nowTs,
updatedAt: nowTs,
});
// Assign the admin to this gym immediately after creation // Assign the admin to this gym immediately after creation
await db.run( await db
sql`UPDATE users SET gym_id = ${id}, updated_at = ${nowTs} WHERE id = ${adminUserId!}`, .update(usersTable)
); .set({ gymId: id, updatedAt: nowTs })
.where(eq(usersTable.id, adminUserId!));
const created = await db.get(sql`SELECT * FROM gyms WHERE id = ${id}`); const created = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.id, id))
.get();
return NextResponse.json(created, { status: 201 }); return NextResponse.json(created, { status: 201 });
} catch (error) { } catch (error) {
log.error("Failed to create gym", error); log.error("Failed to create gym", error);
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
import log from "@/lib/logger";
/**
* POST /api/invitations/[id]/resend
*
* Resend an invitation with same parameters
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id: invitationId } = await params;
// Fetch pending invitations to find the one being resent
const client = await clerkClient();
const invitationList = await client.invitations.getInvitationList({
status: "pending",
});
// Find the invitation
const invitation = invitationList.data.find(
(inv) => inv.id === invitationId,
);
if (!invitation) {
return NextResponse.json(
{ error: "Invitation not found or already processed" },
{ status: 404 },
);
}
const metadata = invitation.publicMetadata as any;
// Check if current user created this invitation
if (metadata?.createdBy !== userId) {
return NextResponse.json(
{ error: "Forbidden - You can only resend invitations you created" },
{ status: 403 },
);
}
// Create new invitation with same parameters
const role = metadata?.role;
// Determine redirect URL based on role
const isStaffRole = ["admin", "trainer", "superAdmin"].includes(role);
const redirectUrl = isStaffRole
? `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/sign-up`
: undefined;
const newInvitation = await client.invitations.createInvitation({
emailAddress: invitation.emailAddress,
publicMetadata: invitation.publicMetadata || undefined,
redirectUrl,
ignoreExisting: true,
});
log.info("Invitation resent", {
originalId: invitationId,
newId: newInvitation.id,
email: invitation.emailAddress,
resentBy: userId,
});
return NextResponse.json({
data: {
invitation: {
id: newInvitation.id,
email: newInvitation.emailAddress,
status: newInvitation.status,
},
},
});
} catch (error) {
log.error("POST /api/invitations/[id]/resend error:", error);
return NextResponse.json(
{ error: "Failed to resend invitation" },
{ status: 500 },
);
}
}
@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server";
import log from "@/lib/logger";
/**
* DELETE /api/invitations/[id]
*
* Revoke a pending invitation (creator-only permission)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id: invitationId } = await params;
// Fetch pending invitations to find and verify the one being revoked
const client = await clerkClient();
const invitationList = await client.invitations.getInvitationList({
status: "pending",
});
// Find the invitation
const invitation = invitationList.data.find(
(inv) => inv.id === invitationId,
);
if (!invitation) {
return NextResponse.json(
{ error: "Invitation not found or already processed" },
{ status: 404 },
);
}
const metadata = invitation.publicMetadata as any;
// Check if current user created this invitation
if (metadata?.createdBy !== userId) {
return NextResponse.json(
{ error: "Forbidden - You can only cancel invitations you created" },
{ status: 403 },
);
}
// Revoke the invitation
await client.invitations.revokeInvitation(invitationId);
log.info("Invitation revoked", {
invitationId,
revokedBy: userId,
email: invitation.emailAddress,
});
return NextResponse.json({
data: { success: true },
});
} catch (error) {
log.error("DELETE /api/invitations/[id] error:", error);
return NextResponse.json(
{ error: "Failed to revoke invitation" },
{ status: 500 },
);
}
}
+88 -3
View File
@@ -1,5 +1,90 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { auth, clerkClient } from "@clerk/nextjs/server"; import { auth, clerkClient } from "@clerk/nextjs/server";
import { getAuthContext } from "@/lib/auth/context";
import { validateGymAccess } from "@/lib/auth/permissions";
import log from "@/lib/logger";
/**
* GET /api/invitations
*
* Fetch pending invitations with gym and creator filtering.
* Users can only see invitations they created, scoped to their gym access.
*
* Query params:
* - gymId: Optional gym filter (superAdmin only)
*/
export async function GET(request: NextRequest) {
try {
const { userId } = await auth();
if (!userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Get auth context
const authContext = await getAuthContext();
const { role, gymId: userGymId } = authContext;
// Get query params
const { searchParams } = new URL(request.url);
const gymIdParam = searchParams.get("gymId");
// Validate gym access
const targetGymId = gymIdParam || undefined;
const accessError = validateGymAccess(role, userGymId, targetGymId);
if (accessError) return accessError;
// Fetch all pending invitations from Clerk
const client = await clerkClient();
const invitationList = await client.invitations.getInvitationList({
status: "pending",
});
// Filter invitations based on:
// 1. Creator (only show invitations created by current user)
// 2. Gym (apply gym-scoping rules)
const filteredInvitations = invitationList.data
.filter((inv) => {
const metadata = inv.publicMetadata as any;
// Creator filter: only show if user created it
if (metadata?.createdBy !== userId) {
return false;
}
// Gym filter: SuperAdmin can see all, others only their gym
if (role === "superAdmin") {
// If gymId param provided, filter by it
if (targetGymId && (metadata?.gymId ?? null) !== targetGymId) {
return false;
}
return true;
} else {
// Non-superAdmins: must match their gym (normalize null/undefined)
return (metadata?.gymId ?? null) === (userGymId ?? null);
}
})
.map((inv) => ({
id: inv.id,
emailAddress: inv.emailAddress,
publicMetadata: inv.publicMetadata,
status: inv.status,
url: inv.url,
createdAt: inv.createdAt,
updatedAt: inv.updatedAt,
revoked: inv.revoked,
}));
return NextResponse.json({
data: { invitations: filteredInvitations },
});
} catch (error) {
log.error("GET /api/invitations error:", error);
return NextResponse.json(
{ error: "Failed to fetch invitations" },
{ status: 500 },
);
}
}
/** /**
* POST /api/invitations * POST /api/invitations
@@ -129,9 +214,9 @@ export async function POST(req: Request) {
const invitation = await client.invitations.createInvitation({ const invitation = await client.invitations.createInvitation({
emailAddress: inviteeEmail, emailAddress: inviteeEmail,
publicMetadata: { publicMetadata: {
roleAssigned, role: roleAssigned,
gymId: gymIdForInvite, gymId: gymIdForInvite,
inviterUserId: inviter.id, createdBy: inviter.id,
}, },
}); });
+38 -5
View File
@@ -84,12 +84,29 @@ export async function POST(request: NextRequest) {
if (data.sendInvitation && data.email) { if (data.sendInvitation && data.email) {
try { try {
const client = await clerkClient(); const client = await clerkClient();
// Build publicMetadata with consistent field names
const publicMetadata: Record<string, any> = {
role: data.role,
createdBy: userId,
gymId: assignedGymId ?? null,
};
// Determine redirect URL based on role
// Staff (admin, trainer, superAdmin) → Admin web app
// Clients → Mobile app (handled by Clerk's default)
const isStaffRole = ["admin", "trainer", "superAdmin"].includes(
data.role,
);
const redirectUrl = isStaffRole
? `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/sign-up`
: undefined; // Clients use default Clerk redirect
const invitation = await client.invitations.createInvitation({ const invitation = await client.invitations.createInvitation({
emailAddress: data.email, emailAddress: data.email,
publicMetadata: { publicMetadata,
role: data.role, redirectUrl,
gymId: assignedGymId, ignoreExisting: true, // Don't fail if invitation already exists
},
}); });
log.info("Clerk invitation sent", { log.info("Clerk invitation sent", {
@@ -97,6 +114,7 @@ export async function POST(request: NextRequest) {
role: data.role, role: data.role,
gymId: assignedGymId, gymId: assignedGymId,
invitationId: invitation.id, invitationId: invitation.id,
redirectUrl: redirectUrl || "default (mobile app)",
}); });
// Send custom invitation email (in addition to Clerk's) // Send custom invitation email (in addition to Clerk's)
@@ -117,7 +135,22 @@ export async function POST(request: NextRequest) {
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "Unknown error"; error instanceof Error ? error.message : "Unknown error";
log.error("Failed to send Clerk invitation", error);
// Extract Clerk-specific error details
const clerkError = error as any;
log.error("Failed to send Clerk invitation", error, {
email: data.email,
role: data.role,
gymId: assignedGymId,
publicMetadata: assignedGymId
? { role: data.role, gymId: assignedGymId }
: { role: data.role },
clerkStatus: clerkError?.status,
clerkErrors: clerkError?.errors,
clerkTraceId: clerkError?.clerkTraceId,
});
return errorResponse(`Failed to send invitation: ${errorMessage}`, { return errorResponse(`Failed to send invitation: ${errorMessage}`, {
status: 500, status: 500,
}); });
+4 -3
View File
@@ -414,14 +414,15 @@ export async function PUT(request: NextRequest) {
return forbiddenResponse(`Not authorized to assign role '${role}'`); return forbiddenResponse(`Not authorized to assign role '${role}'`);
} }
// Authorization: trainers cannot reassign users to different gyms // Authorization: trainers and admins cannot reassign users to different gyms
// Only superAdmins can reassign gyms
if ( if (
requesterRole === "trainer" && requesterRole !== "superAdmin" &&
gymId !== undefined && gymId !== undefined &&
gymId !== existingUser.gymId gymId !== existingUser.gymId
) { ) {
return forbiddenResponse( return forbiddenResponse(
"Trainers cannot reassign users to different gyms", "Only superAdmins can reassign users to different gyms",
); );
} }
+6 -6
View File
@@ -63,22 +63,22 @@ export default function AttendancePage() {
className="border-b border-border/50 hover:bg-muted/50 transition-colors" className="border-b border-border/50 hover:bg-muted/50 transition-colors"
> >
<td className="py-3 px-4 text-sm"> <td className="py-3 px-4 text-sm">
{record.userId.substring(0, 8)}... {record.userName || record.userId.substring(0, 8) + "..."}
</td> </td>
<td className="py-3 px-4 text-sm capitalize text-muted-foreground"> <td className="py-3 px-4 text-sm capitalize text-muted-foreground">
{record.type} {record.type}
</td> </td>
<td className="py-3 px-4 text-sm text-muted-foreground"> <td className="py-3 px-4 text-sm text-muted-foreground">
{new Date(record.checkIn).toLocaleString()} {new Date(record.checkInTime).toLocaleString()}
</td> </td>
<td className="py-3 px-4 text-sm text-muted-foreground"> <td className="py-3 px-4 text-sm text-muted-foreground">
{record.checkOut {record.checkOutTime
? new Date(record.checkOut).toLocaleString() ? new Date(record.checkOutTime).toLocaleString()
: "-"} : "-"}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<Badge variant={record.checkOut ? "gray" : "success"}> <Badge variant={record.checkOutTime ? "gray" : "success"}>
{record.checkOut ? "Completed" : "Active"} {record.checkOutTime ? "Completed" : "Active"}
</Badge> </Badge>
</td> </td>
</tr> </tr>
+10 -4
View File
@@ -199,14 +199,20 @@ export default function SettingsPage() {
setDeletingGym(true); setDeletingGym(true);
setGymMessage(null); setGymMessage(null);
try { try {
await axios.delete(`/api/gyms/${gymId}`); const response = await axios.delete(`/api/gyms/${gymId}`);
log.info("Delete gym response:", response.data);
setGymMessage({ type: "success", text: "Gym deleted successfully" }); setGymMessage({ type: "success", text: "Gym deleted successfully" });
setSelectedGym(null); setSelectedGym(null);
setGymStats(null); setGymStats(null);
fetchGyms(); await fetchGyms();
} catch (error) { } catch (error: any) {
log.error("Failed to delete gym", error); log.error("Failed to delete gym", error);
setGymMessage({ type: "error", text: "Failed to delete gym" }); const errorMessage =
error.response?.data?.error ||
error.response?.data ||
error.message ||
"Failed to delete gym";
setGymMessage({ type: "error", text: errorMessage });
} finally { } finally {
setDeletingGym(false); setDeletingGym(false);
} }
@@ -174,6 +174,10 @@ export function CreateUserModal({
// Reset form // Reset form
resetModal(); resetModal();
// Add delay to allow Clerk API to propagate invitation
await new Promise((resolve) => setTimeout(resolve, 1000));
onSuccess(); onSuccess();
onOpenChange(false); onOpenChange(false);
} catch (error) { } catch (error) {
@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { toast } from "@/lib/toast";
import {
useRevokeInvitation,
useResendInvitation,
type Invitation,
} from "@/hooks/use-api";
import { Copy, RefreshCw, Trash2 } from "lucide-react";
interface InvitationsGridProps {
invitations: Invitation[];
onRefetch: () => void;
}
export function InvitationsGrid({
invitations,
onRefetch,
}: InvitationsGridProps) {
const revokeInvitation = useRevokeInvitation();
const resendInvitation = useResendInvitation();
const [actioningId, setActioningId] = useState<string | null>(null);
const handleCopyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
toast.success("Invitation link copied to clipboard");
} catch (error) {
toast.error("Failed to copy link");
}
};
const handleRevoke = async (invitation: Invitation) => {
if (!confirm(`Cancel invitation for ${invitation.emailAddress}?`)) {
return;
}
setActioningId(invitation.id);
try {
await revokeInvitation.mutateAsync(invitation.id);
toast.success("Invitation cancelled");
onRefetch();
} catch (error) {
toast.error("Failed to cancel invitation");
} finally {
setActioningId(null);
}
};
const handleResend = async (invitation: Invitation) => {
setActioningId(invitation.id);
try {
await resendInvitation.mutateAsync(invitation.id);
toast.success("Invitation resent");
onRefetch();
} catch (error) {
toast.error("Failed to resend invitation");
} finally {
setActioningId(null);
}
};
if (invitations.length === 0) {
return (
<Card className="p-8 text-center text-muted-foreground">
No pending invitations
</Card>
);
}
return (
<div className="space-y-4">
{invitations.map((invitation) => {
const role = invitation.publicMetadata?.role || "unknown";
const createdDate = new Date(invitation.createdAt);
const isActioning = actioningId === invitation.id;
return (
<Card key={invitation.id} className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium">{invitation.emailAddress}</h3>
<span className="text-xs px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
<span className="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{role}
</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
Sent {formatDistanceToNow(createdDate, { addSuffix: true })}
</p>
</div>
<div className="flex items-center gap-2">
{invitation.url && (
<Button
variant="outline"
size="sm"
onClick={() => handleCopyUrl(invitation.url!)}
>
<Copy className="h-4 w-4 mr-1" />
Copy Link
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleResend(invitation)}
disabled={isActioning}
>
<RefreshCw
className={`h-4 w-4 mr-1 ${isActioning ? "animate-spin" : ""}`}
/>
Resend
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleRevoke(invitation)}
disabled={isActioning}
>
<Trash2 className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
</Card>
);
})}
</div>
);
}
+1 -1
View File
@@ -260,7 +260,7 @@ export function UserGrid({
// }, // },
{ {
headerName: "Last Visit", headerName: "Last Visit",
valueGetter: (params) => params.data?.client?.lastVisit, valueGetter: (params) => params.data?.lastCheckInTime,
filter: "agDateColumnFilter", filter: "agDateColumnFilter",
sortable: true, sortable: true,
valueFormatter: (params: any) => valueFormatter: (params: any) =>
@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { UserGrid, type User } from "@/components/users/UserGrid"; import { UserGrid, type User } from "@/components/users/UserGrid";
import { InvitationsGrid } from "@/components/users/InvitationsGrid";
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"; import { useUser } from "@clerk/nextjs";
@@ -14,6 +15,7 @@ import {
useUpdateUser, useUpdateUser,
useDeleteUser, useDeleteUser,
useSendInvitation, useSendInvitation,
useInvitations,
} from "@/hooks/use-api"; } from "@/hooks/use-api";
interface UserManagementProps { interface UserManagementProps {
@@ -23,6 +25,9 @@ interface UserManagementProps {
export function UserManagement({ gymId }: UserManagementProps) { export function UserManagement({ gymId }: UserManagementProps) {
const { user } = useUser(); const { user } = useUser();
const [filter, setFilter] = useState<string>("all"); const [filter, setFilter] = useState<string>("all");
const [viewFilter, setViewFilter] = useState<"all" | "active" | "pending">(
"all",
);
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
@@ -40,17 +45,28 @@ export function UserManagement({ gymId }: UserManagementProps) {
const { const {
data: users = [], data: users = [],
isLoading, isLoading: usersLoading,
refetch, refetch: refetchUsers,
} = useUsers({ } = useUsers({
role: filter !== "all" ? filter : undefined, role: filter !== "all" ? filter : undefined,
gymId, gymId,
}); });
const {
data: invitations = [],
isLoading: invitationsLoading,
refetch: refetchInvitations,
} = useInvitations(gymId);
const { data: gyms = [] } = useGyms(); const { data: gyms = [] } = useGyms();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
const deleteUser = useDeleteUser(); const deleteUser = useDeleteUser();
const sendInvitation = useSendInvitation(); const sendInvitation = useSendInvitation();
const isLoading =
viewFilter === "pending" ? invitationsLoading : usersLoading;
const refetch = viewFilter === "pending" ? refetchInvitations : refetchUsers;
const handleUserSelect = (user: User | null) => { const handleUserSelect = (user: User | null) => {
setSelectedUser(user); setSelectedUser(user);
}; };
@@ -193,6 +209,19 @@ export function UserManagement({ gymId }: UserManagementProps) {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">User Management</h2> <h2 className="text-2xl font-bold">User Management</h2>
<div className="flex gap-2"> <div className="flex gap-2">
{/* View Filter: All/Active/Pending */}
<select
value={viewFilter}
onChange={(e) =>
setViewFilter(e.target.value as "all" | "active" | "pending")
}
className="px-3 py-2 border rounded-md bg-white dark:bg-gray-800"
>
<option value="all">All Users</option>
<option value="active">Active Users</option>
<option value="pending">Pending Invitations</option>
</select>
<Button <Button
variant={filter === "all" ? "default" : "outline"} variant={filter === "all" ? "default" : "outline"}
onClick={() => setFilter("all")} onClick={() => setFilter("all")}
@@ -220,7 +249,7 @@ export function UserManagement({ gymId }: UserManagementProps) {
variant={filter === "client" ? "default" : "outline"} variant={filter === "client" ? "default" : "outline"}
onClick={() => setFilter("client")} onClick={() => setFilter("client")}
> >
Clientsa Clients
</Button> </Button>
<Button <Button
variant={filter === "trainer" ? "default" : "outline"} variant={filter === "trainer" ? "default" : "outline"}
@@ -245,33 +274,48 @@ export function UserManagement({ gymId }: UserManagementProps) {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
Showing {users.length} users {viewFilter === "pending" ? (
{selectedUser && ( <>Showing {invitations.length} pending invitations</>
<span className="ml-4 text-blue-600"> ) : (
Selected: {selectedUser.firstName} {selectedUser.lastName} <>
</span> Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
)}
</>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="default" onClick={handleRefresh}> <Button variant="default" onClick={handleRefresh}>
Refresh Refresh
</Button> </Button>
<Button variant="default" onClick={handleExport}> {viewFilter !== "pending" && (
Export CSV <Button variant="default" onClick={handleExport}>
</Button> Export CSV
</Button>
)}
</div> </div>
</div> </div>
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
<UserGrid {viewFilter === "pending" ? (
users={users} <InvitationsGrid
onUserSelect={(user) => handleUserSelect(user)} invitations={invitations}
onEditUser={handleEditUser} onRefetch={refetchInvitations}
onDeleteUser={handleDeleteUser} />
onBulkDelete={handleBulkDelete} ) : (
loading={isLoading} <UserGrid
/> users={users}
onUserSelect={(user) => handleUserSelect(user)}
onEditUser={handleEditUser}
onDeleteUser={handleDeleteUser}
onBulkDelete={handleBulkDelete}
loading={isLoading}
/>
)}
</CardContent> </CardContent>
</Card> </Card>
@@ -362,8 +406,12 @@ export function UserManagement({ gymId }: UserManagementProps) {
/> />
</div> </div>
{/* Only superAdmins and admins can reassign gyms */} {/* Only superAdmins can reassign gyms */}
{user?.publicMetadata?.role !== "trainer" && ( {(() => {
const currentRole = user?.publicMetadata?.role;
console.log("Current user role for gym selector:", currentRole);
return currentRole === "superAdmin";
})() && (
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium mb-1">Gym</label> <label className="block text-sm font-medium mb-1">Gym</label>
<select <select
@@ -539,9 +587,9 @@ export function UserManagement({ gymId }: UserManagementProps) {
</p> </p>
<p> <p>
<span className="font-medium">Last Visit:</span>{" "} <span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit {selectedUser.lastCheckInTime
? new Date( ? new Date(
selectedUser.client.lastVisit, selectedUser.lastCheckInTime,
).toLocaleDateString() ).toLocaleDateString()
: "Never"} : "Never"}
</p> </p>
@@ -576,7 +624,10 @@ export function UserManagement({ gymId }: UserManagementProps) {
<CreateUserModal <CreateUserModal
open={createModalOpen} open={createModalOpen}
onOpenChange={setCreateModalOpen} onOpenChange={setCreateModalOpen}
onSuccess={() => refetch()} onSuccess={() => {
refetchUsers();
refetchInvitations();
}}
/> />
</div> </div>
); );
+74 -7
View File
@@ -55,12 +55,29 @@ export interface Gym {
export interface AttendanceRecord { export interface AttendanceRecord {
id: string; id: string;
userId: string; userId: string;
checkIn: string; userName?: string;
checkOut?: string; userEmail?: string;
checkInTime: Date;
checkOutTime?: Date;
date: string; date: string;
type?: string; type?: string;
} }
export interface Invitation {
id: string;
emailAddress: string;
publicMetadata: {
role?: string;
gymId?: string;
createdBy?: string;
} | null;
status: "pending" | "accepted" | "revoked" | "expired";
url?: string;
createdAt: number;
updatedAt: number;
revoked?: boolean;
}
async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> { async function fetchApi<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
@@ -339,13 +356,32 @@ export function useCheckOut() {
}); });
} }
export function useInvitations() { export function useInvitations(gymId?: string) {
return useQuery({ return useQuery({
queryKey: ["invitations"], queryKey: ["invitations", gymId],
queryFn: () => queryFn: () => {
fetchApi<{ data: { invitations: unknown[] } }>("/api/invitations").then( const url = gymId
? `/api/invitations?gymId=${gymId}`
: "/api/invitations";
return fetchApi<{ data: { invitations: Invitation[] } }>(url).then(
(res) => res.data?.invitations ?? [], (res) => res.data?.invitations ?? [],
), );
},
refetchInterval: (query) => {
const data = query.state.data;
const hasData = data && data.length > 0;
const fetchCount = query.state.dataUpdateCount;
// Poll every 2 seconds if:
// 1. No invitations returned yet
// 2. Haven't exceeded 5 attempts (10 seconds total)
if (!hasData && fetchCount < 5) {
return 2000;
}
// Stop polling after 10 seconds or when data is present
return false;
},
}); });
} }
@@ -364,6 +400,37 @@ export function useSendInvitation() {
}); });
} }
export function useRevokeInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (invitationId: string) =>
fetchApi<{ data: { success: boolean } }>(
`/api/invitations/${invitationId}`,
{ method: "DELETE" },
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invitations"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
},
});
}
export function useResendInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (invitationId: string) =>
fetchApi<{ data: { invitation: any } }>(
`/api/invitations/${invitationId}/resend`,
{ method: "POST" },
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invitations"] });
},
});
}
export interface AnalyticsData { export interface AnalyticsData {
userGrowth: { label: string; value: number }[]; userGrowth: { label: string; value: number }[];
membershipDistribution: { label: string; value: number; color: string }[]; membershipDistribution: { label: string; value: number; color: string }[];
+18 -5
View File
@@ -1318,9 +1318,14 @@ export class DrizzleDatabase implements IDatabase {
membershipStatus: String( membershipStatus: String(
row.membershipStatus, row.membershipStatus,
) as Client["membershipStatus"], ) as Client["membershipStatus"],
joinDate: new Date(row.joinDate as number | Date), joinDate:
typeof row.joinDate === "number"
? new Date(row.joinDate * 1000)
: new Date(row.joinDate as Date),
lastVisit: row.lastVisit lastVisit: row.lastVisit
? new Date(row.lastVisit as number | Date) ? typeof row.lastVisit === "number"
? new Date(row.lastVisit * 1000)
: new Date(row.lastVisit as Date)
: undefined, : undefined,
emergencyContact: row.emergencyContactName emergencyContact: row.emergencyContactName
? { ? {
@@ -1363,12 +1368,20 @@ export class DrizzleDatabase implements IDatabase {
id: String(row.id), id: String(row.id),
userId: String(row.userId), userId: String(row.userId),
type: String(row.type) as Attendance["type"], type: String(row.type) as Attendance["type"],
checkInTime: new Date(row.checkInTime as number | Date), checkInTime:
typeof row.checkInTime === "number"
? new Date(row.checkInTime * 1000)
: new Date(row.checkInTime as Date),
checkOutTime: row.checkOutTime checkOutTime: row.checkOutTime
? new Date(row.checkOutTime as number | Date) ? typeof row.checkOutTime === "number"
? new Date(row.checkOutTime * 1000)
: new Date(row.checkOutTime as Date)
: undefined, : undefined,
notes: row.notes ? String(row.notes) : undefined, notes: row.notes ? String(row.notes) : undefined,
createdAt: new Date(row.createdAt as number | Date), createdAt:
typeof row.createdAt === "number"
? new Date(row.createdAt * 1000)
: new Date(row.createdAt as Date),
}; };
} }
+6 -3
View File
@@ -16,9 +16,12 @@ export const passwordSchema = z
export const phoneSchema = z export const phoneSchema = z
.string() .string()
.optional() .optional()
.refine((val) => !val || /^\+?[1-9]\d{1,14}$/.test(val), { .refine(
message: "Invalid phone number format", (val) => !val || val.trim() === "" || /^\+?[1-9]\d{1,14}$/.test(val),
}); {
message: "Invalid phone number format",
},
);
export const dateTimeSchema = z.string().datetime("Invalid datetime format"); export const dateTimeSchema = z.string().datetime("Invalid datetime format");
+2 -2
View File
@@ -53,10 +53,10 @@ export const gyms = sqliteTable(
adminUserId: text("admin_user_id") adminUserId: text("admin_user_id")
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp" }) createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" }) updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull() .notNull()
.$defaultFn(() => new Date()), .$defaultFn(() => new Date()),
}, },