Compare 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
15 changed files with 163 additions and 67 deletions

No files matched your search

Binary file not shown.
@@ -34,7 +34,33 @@ export async function GET(req: NextRequest) {
? await getAttendanceByGym(targetGymId)
: 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) {
console.error("Admin attendance error:", error);
return new NextResponse("Internal Server Error", { status: 500 });
+24 -14
View File
@@ -1,7 +1,7 @@
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 { db, users as usersTable, gyms as gymsTable } from "@fitai/database";
import { ensureUserSynced } from "@/lib/sync-user";
import log from "@/lib/logger";
@@ -30,7 +30,7 @@ export async function DELETE(
const { userId } = await auth();
if (!userId) {
return new NextResponse("Unauthorized", { status: 401 });
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Ensure user is synced
@@ -60,23 +60,30 @@ export async function DELETE(
// Only superAdmin can delete gyms
if (!currentUser || currentUser.role !== "superAdmin") {
return new NextResponse("Forbidden - Only superAdmin can delete gyms", {
status: 403,
});
return NextResponse.json(
{ error: "Forbidden - Only superAdmin can delete gyms" },
{ status: 403 },
);
}
await ensureGymsTable();
// Check if gym exists
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`);
if (gymRows.length === 0) {
return new NextResponse("Gym not found", { status: 404 });
// Check if gym exists using Drizzle ORM
const existingGym = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.id, gymId))
.get();
if (!existingGym) {
return NextResponse.json({ error: "Gym not found" }, { status: 404 });
}
// Soft delete - mark as inactive
await db.run(
sql`UPDATE gyms SET status = 'inactive', updated_at = ${Date.now()} WHERE id = ${gymId}`,
);
// Soft delete - mark as inactive using Drizzle ORM
await db
.update(gymsTable)
.set({ status: "inactive", updatedAt: new Date() })
.where(eq(gymsTable.id, gymId));
return NextResponse.json({
success: true,
@@ -84,6 +91,9 @@ export async function DELETE(
});
} catch (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 { eq, sql } from "@fitai/database";
import { db } from "@fitai/database";
import { db, gyms as gymsTable } from "@fitai/database";
import log from "@/lib/logger";
async function ensureGymsTable() {
@@ -27,12 +27,16 @@ export async function GET(
const { id: gymId } = await params;
await ensureGymsTable();
// Get gym info
const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`);
if (gymRows.length === 0) {
return new NextResponse("Gym not found", { status: 404 });
// Get gym info using Drizzle ORM
const gym = await db
.select()
.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
const usersResult = await db.all(
@@ -76,7 +80,8 @@ export async function GET(
}
// 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(
sql`SELECT COUNT(*) as count FROM attendance
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 });
} catch (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 { auth } from "@clerk/nextjs/server";
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 log from "@/lib/logger";
@@ -24,9 +24,12 @@ async function ensureGymsTable() {
export async function GET() {
try {
await ensureGymsTable();
const rows = await db.all(
sql`SELECT * FROM gyms WHERE status = 'active' ORDER BY created_at DESC`,
);
const rows = await db
.select()
.from(gymsTable)
.where(eq(gymsTable.status, "active"))
.orderBy(sql`created_at DESC`)
.all();
return NextResponse.json(rows);
} catch (error) {
@@ -146,19 +149,30 @@ export async function POST(req: Request) {
}
const id = generateId();
const nowTs = Date.now();
const nowTs = new Date();
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})`,
);
// Use Drizzle's insert method instead of raw SQL
await db.insert(gymsTable).values({
id,
name,
location: location ?? null,
status: "active",
adminUserId: adminUserId!,
createdAt: nowTs,
updatedAt: 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!}`,
);
await db
.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 });
} catch (error) {
log.error("Failed to create gym", error);
+5 -5
View File
@@ -54,13 +54,13 @@ export async function GET(request: NextRequest) {
// Gym filter: SuperAdmin can see all, others only their gym
if (role === "superAdmin") {
// If gymId param provided, filter by it
if (targetGymId && metadata?.gymId !== targetGymId) {
if (targetGymId && (metadata?.gymId ?? null) !== targetGymId) {
return false;
}
return true;
} else {
// Non-superAdmins: must match their gym
return metadata?.gymId === userGymId;
// Non-superAdmins: must match their gym (normalize null/undefined)
return (metadata?.gymId ?? null) === (userGymId ?? null);
}
})
.map((inv) => ({
@@ -214,9 +214,9 @@ export async function POST(req: Request) {
const invitation = await client.invitations.createInvitation({
emailAddress: inviteeEmail,
publicMetadata: {
roleAssigned,
role: roleAssigned,
gymId: gymIdForInvite,
inviterUserId: inviter.id,
createdBy: inviter.id,
},
});
+2 -4
View File
@@ -85,14 +85,12 @@ export async function POST(request: NextRequest) {
try {
const client = await clerkClient();
// Build publicMetadata - only include gymId if it exists
// Build publicMetadata with consistent field names
const publicMetadata: Record<string, any> = {
role: data.role,
createdBy: userId,
gymId: assignedGymId ?? null,
};
if (assignedGymId) {
publicMetadata.gymId = assignedGymId;
}
// Determine redirect URL based on role
// Staff (admin, trainer, superAdmin) → Admin web app
+6 -6
View File
@@ -63,22 +63,22 @@ export default function AttendancePage() {
className="border-b border-border/50 hover:bg-muted/50 transition-colors"
>
<td className="py-3 px-4 text-sm">
{record.userId.substring(0, 8)}...
{record.userName || record.userId.substring(0, 8) + "..."}
</td>
<td className="py-3 px-4 text-sm capitalize text-muted-foreground">
{record.type}
</td>
<td className="py-3 px-4 text-sm text-muted-foreground">
{new Date(record.checkIn).toLocaleString()}
{new Date(record.checkInTime).toLocaleString()}
</td>
<td className="py-3 px-4 text-sm text-muted-foreground">
{record.checkOut
? new Date(record.checkOut).toLocaleString()
{record.checkOutTime
? new Date(record.checkOutTime).toLocaleString()
: "-"}
</td>
<td className="py-3 px-4">
<Badge variant={record.checkOut ? "gray" : "success"}>
{record.checkOut ? "Completed" : "Active"}
<Badge variant={record.checkOutTime ? "gray" : "success"}>
{record.checkOutTime ? "Completed" : "Active"}
</Badge>
</td>
</tr>
+10 -4
View File
@@ -199,14 +199,20 @@ export default function SettingsPage() {
setDeletingGym(true);
setGymMessage(null);
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" });
setSelectedGym(null);
setGymStats(null);
fetchGyms();
} catch (error) {
await fetchGyms();
} catch (error: any) {
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 {
setDeletingGym(false);
}
@@ -174,6 +174,10 @@ export function CreateUserModal({
// Reset form
resetModal();
// Add delay to allow Clerk API to propagate invitation
await new Promise((resolve) => setTimeout(resolve, 1000));
onSuccess();
onOpenChange(false);
} catch (error) {
+1 -1
View File
@@ -260,7 +260,7 @@ export function UserGrid({
// },
{
headerName: "Last Visit",
valueGetter: (params) => params.data?.client?.lastVisit,
valueGetter: (params) => params.data?.lastCheckInTime,
filter: "agDateColumnFilter",
sortable: true,
valueFormatter: (params: any) =>
@@ -587,9 +587,9 @@ export function UserManagement({ gymId }: UserManagementProps) {
</p>
<p>
<span className="font-medium">Last Visit:</span>{" "}
{selectedUser.client.lastVisit
{selectedUser.lastCheckInTime
? new Date(
selectedUser.client.lastVisit,
selectedUser.lastCheckInTime,
).toLocaleDateString()
: "Never"}
</p>
+19 -2
View File
@@ -55,8 +55,10 @@ export interface Gym {
export interface AttendanceRecord {
id: string;
userId: string;
checkIn: string;
checkOut?: string;
userName?: string;
userEmail?: string;
checkInTime: Date;
checkOutTime?: Date;
date: string;
type?: string;
}
@@ -365,6 +367,21 @@ export function useInvitations(gymId?: string) {
(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;
},
});
}
+18 -5
View File
@@ -1318,9 +1318,14 @@ export class DrizzleDatabase implements IDatabase {
membershipStatus: String(
row.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
? new Date(row.lastVisit as number | Date)
? typeof row.lastVisit === "number"
? new Date(row.lastVisit * 1000)
: new Date(row.lastVisit as Date)
: undefined,
emergencyContact: row.emergencyContactName
? {
@@ -1363,12 +1368,20 @@ export class DrizzleDatabase implements IDatabase {
id: String(row.id),
userId: String(row.userId),
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
? new Date(row.checkOutTime as number | Date)
? typeof row.checkOutTime === "number"
? new Date(row.checkOutTime * 1000)
: new Date(row.checkOutTime as Date)
: 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),
};
}
+2 -2
View File
@@ -53,10 +53,10 @@ export const gyms = sqliteTable(
adminUserId: text("admin_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp" })
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.notNull()
.$defaultFn(() => new Date()),
},