FitnessApp/app/page.tsx

331 lines
15 KiB
TypeScript

import Link from 'next/link';
export const dynamic = 'force-dynamic';
import { prisma } from '@/lib/prisma';
import { getServiceAddress } from '@/lib/service-address';
import Redis from 'ioredis';
import { S3Client, ListBucketsCommand } from '@aws-sdk/client-s3';
import { createDemoTenantAction, clearDemoDataAction } from './actions';
// Dynamic rendering handled via async data
async function checkPostgres() {
try {
const raw = await prisma.$queryRaw<Array<{ status: number }>>`SELECT 1 as status;`;
return { ok: true, message: `Connected (SELECT 1 -> ${raw[0]?.status ?? 1})` };
} catch (err: any) {
return { ok: false, message: err.message };
}
}
async function checkRedis() {
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) return { ok: false, message: 'REDIS_URL not configured' };
const client = new Redis(redisUrl, {
lazyConnect: true,
maxRetriesPerRequest: 1,
connectTimeout: 2000,
commandTimeout: 2000,
retryStrategy: () => null,
});
// Register before ping starts the connection. Surface failures in the health
// card instead of ioredis logging an unhandled error to the dev overlay.
let connectionError: Error | undefined;
client.on('error', (error: Error) => {
connectionError = error;
});
try {
const pong = await client.ping();
return { ok: pong === 'PONG', message: `Connected (PING -> ${pong})` };
} catch (err: unknown) {
const message = connectionError?.message ?? (err instanceof Error ? err.message : 'Redis connection failed');
return { ok: false, message };
} finally {
client.disconnect();
}
}
async function checkS3() {
const endpoint = process.env.S3_ENDPOINT;
const accessKeyId = process.env.S3_ACCESS_KEY;
const secretAccessKey = process.env.S3_SECRET_KEY;
if (!endpoint || !accessKeyId || !secretAccessKey) {
return { ok: false, message: 'MinIO credentials missing' };
}
const s3 = new S3Client({
endpoint,
region: 'us-east-1',
credentials: { accessKeyId, secretAccessKey },
forcePathStyle: true,
});
try {
const res = await s3.send(new ListBucketsCommand({}));
const count = res.Buckets?.length ?? 0;
return { ok: true, message: `Connected (${count} buckets listed)` };
} catch (err: any) {
return { ok: false, message: err.message };
}
}
export default async function Home() {
const [pgStatus, redisStatus, s3Status] = await Promise.all([
checkPostgres(),
checkRedis(),
checkS3(),
]);
const [tenants, userCount, sessionCount, bookingCount, waiverCount] = await Promise.all([
prisma.tenant.findMany({
include: {
users: true,
classSessions: true,
bookings: true,
eWaivers: true,
},
orderBy: { createdAt: 'desc' },
take: 5,
}),
prisma.user.count(),
prisma.classSession.count(),
prisma.booking.count(),
prisma.eWaiver.count(),
]);
return (
<div className="min-h-screen bg-neutral-950 text-neutral-100 font-sans p-6 md:p-12">
<div className="max-w-6xl mx-auto space-y-8">
{/* Header */}
<header className="border-b border-neutral-800 pb-6 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold uppercase tracking-wider mb-2">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
Milestone 1 Verified
</div>
<h1 className="text-3xl font-bold tracking-tight text-white">Fitness App POC & Multi-Tenant Engine</h1>
<p className="text-sm text-neutral-400 mt-1">
Next.js 16 (App Router) • Prisma ORM • PostgreSQL • Redis • MinIO S3
</p>
</div>
<div className="flex items-center gap-3">
<form action={createDemoTenantAction}>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-lg shadow transition"
>
+ Seed Demo Tenant
</button>
</form>
{tenants.length > 0 && (
<form action={clearDemoDataAction}>
<button
type="submit"
className="px-4 py-2 bg-neutral-800 hover:bg-red-900/60 text-neutral-300 hover:text-red-300 text-sm font-medium rounded-lg border border-neutral-700 transition"
>
Clear Data
</button>
</form>
)}
</div>
</header>
{/* Live Service Health Grid */}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{/* PostgreSQL Card */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-xl p-5 flex flex-col justify-between">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Database</span>
<span className={`inline-block w-2.5 h-2.5 rounded-full ${pgStatus.ok ? 'bg-emerald-400' : 'bg-red-500'}`} />
</div>
<div>
<h3 className="text-lg font-semibold text-white">PostgreSQL</h3>
<p className="text-xs text-neutral-400 mt-1">{getServiceAddress(process.env.DATABASE_URL)}</p>
<p className={`text-xs mt-2 truncate ${pgStatus.ok ? 'text-emerald-400 font-mono' : 'text-red-400'}`}>
{pgStatus.message}
</p>
</div>
</div>
{/* Redis Card */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-xl p-5 flex flex-col justify-between">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Cache / Queue</span>
<span className={`inline-block w-2.5 h-2.5 rounded-full ${redisStatus.ok ? 'bg-emerald-400' : 'bg-red-500'}`} />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Redis 7</h3>
<p className="text-xs text-neutral-400 mt-1">{getServiceAddress(process.env.REDIS_URL)}</p>
<p className={`text-xs mt-2 truncate ${redisStatus.ok ? 'text-emerald-400 font-mono' : 'text-red-400'}`}>
{redisStatus.message}
</p>
</div>
</div>
{/* MinIO S3 Card */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-xl p-5 flex flex-col justify-between">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Object Storage</span>
<span className={`inline-block w-2.5 h-2.5 rounded-full ${s3Status.ok ? 'bg-emerald-400' : 'bg-red-500'}`} />
</div>
<div>
<h3 className="text-lg font-semibold text-white">MinIO S3</h3>
<p className="text-xs text-neutral-400 mt-1">{getServiceAddress(process.env.S3_ENDPOINT)}</p>
<p className={`text-xs mt-2 truncate ${s3Status.ok ? 'text-emerald-400 font-mono' : 'text-red-400'}`}>
{s3Status.message}
</p>
</div>
</div>
{/* Multi-Tenant Extension Card */}
<div className="bg-neutral-900/80 border border-neutral-800 rounded-xl p-5 flex flex-col justify-between">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-400">Prisma Client</span>
<span className="inline-block w-2.5 h-2.5 rounded-full bg-emerald-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">$extends Filter</h3>
<p className="text-xs text-neutral-400 mt-1">lib/prisma.ts</p>
<p className="text-xs mt-2 text-emerald-400 font-mono">
tenantId auto-scoping active
</p>
</div>
</div>
</section>
{/* Database Metric Stats */}
<section className="bg-neutral-900/40 border border-neutral-800 rounded-xl p-6">
<h2 className="text-sm font-semibold uppercase tracking-wider text-neutral-400 mb-4">
Live Schema Metrics
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{tenants.length}</div>
<div className="text-xs text-neutral-400 mt-1">Tenants (Gyms)</div>
</div>
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{userCount}</div>
<div className="text-xs text-neutral-400 mt-1">Users</div>
</div>
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{sessionCount}</div>
<div className="text-xs text-neutral-400 mt-1">Class Sessions</div>
</div>
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{bookingCount}</div>
<div className="text-xs text-neutral-400 mt-1">Bookings</div>
</div>
<div className="bg-neutral-900 border border-neutral-800/80 rounded-lg p-4">
<div className="text-2xl font-bold text-white">{waiverCount}</div>
<div className="text-xs text-neutral-400 mt-1">E-Waivers</div>
</div>
</div>
</section>
{/* Tenants Section */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Registered Tenants & Scoped Records</h2>
<span className="text-xs text-neutral-400">Showing recent tenants</span>
</div>
{tenants.length === 0 ? (
<div className="border border-dashed border-neutral-800 rounded-xl p-12 text-center">
<p className="text-neutral-400 text-sm">No tenants registered in the database yet.</p>
<p className="text-neutral-500 text-xs mt-1">
Click <span className="text-emerald-400 font-semibold">&quot;+ Seed Demo Tenant&quot;</span> in the header to create an isolated gym tenant with sample users and classes.
</p>
</div>
) : (
<div className="space-y-3">
{tenants.map((t) => (
<div
key={t.id}
className="bg-neutral-900 border border-neutral-800 rounded-xl p-5 hover:border-neutral-700 transition"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-neutral-800 pb-3">
<div>
<h3 className="text-base font-semibold text-white">{t.name}</h3>
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-400 font-mono">
<span>slug: {t.slug}</span>
<span>•</span>
<span>domain: {t.domain ?? 'n/a'}</span>
<span>•</span>
<span className="text-neutral-500">ID: {t.id}</span>
</div>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="px-2 py-0.5 rounded bg-neutral-800 text-neutral-300">
{t.users.length} Users
</span>
<span className="px-2 py-0.5 rounded bg-neutral-800 text-neutral-300">
{t.classSessions.length} Classes
</span>
<span className="px-2 py-0.5 rounded bg-neutral-800 text-neutral-300">
{t.bookings.length} Bookings
</span>
</div>
</div>
{/* Users breakdown */}
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div>
<span className="text-neutral-400 font-medium">Tenant Users:</span>
<div className="mt-1 space-y-1">
{t.users.map((u) => (
<div key={u.id} className="flex items-center justify-between bg-neutral-950 px-2.5 py-1.5 rounded border border-neutral-800/60">
<span className="text-neutral-200">{u.name} ({u.email})</span>
<span className="px-1.5 py-0.5 rounded text-[10px] bg-neutral-800 text-neutral-400 font-mono">
{u.role}
</span>
</div>
))}
</div>
</div>
<div>
<span className="text-neutral-400 font-medium">Class Sessions:</span>
<div className="mt-1 space-y-1">
{t.classSessions.map((cs) => (
<div key={cs.id} className="bg-neutral-950 px-2.5 py-1.5 rounded border border-neutral-800/60 flex items-center justify-between">
<span className="text-neutral-200">{cs.title}</span>
<span className="text-neutral-400 text-[10px] font-mono">Cap: {cs.capacity}</span>
</div>
))}
</div>
</div>
</div>
<div className="mt-4 pt-3 border-t border-neutral-800 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="text-xs text-neutral-400">
Live Virtual Classroom:
</div>
<div className="flex items-center gap-2">
<Link
href={`/${t.slug}/classes`}
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg shadow transition flex items-center gap-1.5"
>
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
Student Schedule & Live Room →
</Link>
<Link
href={`/${t.slug}/instructor/classes`}
className="px-3 py-1.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-200 text-xs font-medium rounded-lg border border-neutral-700 transition"
>
Instructor Portal →
</Link>
</div>
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}