commit 491e2f54afb3aa81061194cc57195c33037e096a Author: Albert Blais Date: Sun Oct 11 12:16:20 2026 -0400 Import fitness app with classroom layout and service health fixes diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f60407 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# Copy to .env.local and fill blank values locally. Never commit real credentials. +DATABASE_URL= +REDIS_URL= +S3_ENDPOINT=http://192.168.16.6:9000 +S3_ACCESS_KEY= +S3_SECRET_KEY= +JITSI_DOMAIN=jitsi.my127001.ca +JITSI_PUBLIC_URL=https://jitsi.my127001.ca +JITSI_APP_ID=fitness-app +# Must match the signing key configured on your Jitsi server. +JITSI_APP_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2a1ad95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# local credentials, databases, and backup files +*.key +*.pfx +*.p12 +*.db +*.db-* +*.sqlite* +*.bak + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..af3b7c8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ + + +## This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9b3c79 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Fitness App + +A Next.js proof of concept for a multi-tenant fitness and yoga platform with live Jitsi classrooms. + +## Current work + +- Milestone 1: schema, migrations, dashboard probes, and basic tenant-scoped Prisma queries are implemented. PostgreSQL, Redis, and MinIO connections were confirmed on the Windows development PC. +- Milestone 2: class schedules, instructor scheduling, join APIs, and Jitsi classrooms are implemented. Instructor and student viewport sizing were confirmed in live sessions; simulated API/fallback layout checks passed at seven screen sizes. +- Milestone 3: video uploads, MinIO buckets/CORS, BullMQ processing, and playback UI remain to be built. +- Milestone 4: recurring scheduling, payments, waivers, authentication, and administration remain to be completed. Add a system-admin-only settings area for service endpoints, validated updates, health checks, and protected credentials. + +## Local development + +Keep the existing Windows project and its `.env`/`.env.local` files. This checkpoint does not include those files or change installed dependency versions. + +For a fresh checkout: + +1. Copy `.env.example` to `.env.local` and fill the blank connection strings and credentials locally. Jitsi's signing key must match the Jitsi server. Do not use a generated replacement key without configuring both sides. +2. Run `npm ci` and `npx prisma generate`. +3. For a new database, apply the included migrations with `npx prisma migrate deploy`. +4. Run `npm run dev` and open the local address printed in the terminal. + +Environment files belong at the project root, not under `src`. The dashboard displays only endpoint hosts/ports, without URL usernames, passwords, or queries. + +The current verified service addresses are PostgreSQL `192.168.16.90:5432`, Redis `192.168.16.3:6379`, and MinIO `192.168.16.6:9000`. These are configuration values, not fixed service-discovery guarantees. + +## Project layout + +`app/`, `components/`, and `lib/` are the active source roots because the TypeScript `@/*` alias points to the repository root. Existing duplicate classroom/Jitsi files under `src/` are retained and synchronized for compatibility. Do not create a second `src/app/` router alongside the root `app/` tree. + +Prisma schema and migrations are under `prisma/`. Connection and Jitsi integration scripts are under `scripts/`. + +## Validation + +- `npm run build`: passes and includes TypeScript checking. +- `npm run lint`: existing lint issues remain; this checkpoint does not declare a clean lint result. +- `npm run test:connections`: requires your configured services and performs a temporary Redis key write/read/delete. +- `npm run test:jitsi`: requires configured services and creates test tenant/users/classes/bookings. Run against a development database; it is not a read-only health check. + +Offline token, service-address, Redis socket/error, and responsive layout checks were run while preparing the checkpoint. Cloud checks do not replace real Windows camera/microphone and service tests. + +## Known limitations + +This is a development proof of concept, not a production-ready access-control system. Login/session enforcement is absent; request-supplied user IDs/roles and demo users are not trusted authentication. Instructor actions and demo data actions need permissions. The Clear Data action currently deletes all tenants' records. Booking capacity checks are not concurrency-safe, and joins can mark a waiver timestamp without a real waiver signature. Tenant extensions still require a broader isolation review, including related/nested writes. + +Hardcoded Jitsi and Redis credential fallbacks have been removed. Real credentials stay outside Git; `.env.example` contains blank credential fields. Protect the local environment files and do not force-add them to Git. + +The original Windows project can be reopened in Antigravity. Follow `AGENTS.md` and the installed Next.js guides before further changes. Generated `.next/`, `next-env.d.ts`, and dependency directories are excluded from Git. diff --git a/app/[tenant]/classes/page.tsx b/app/[tenant]/classes/page.tsx new file mode 100644 index 0000000..b684893 --- /dev/null +++ b/app/[tenant]/classes/page.tsx @@ -0,0 +1,131 @@ +export const dynamic = 'force-dynamic'; +import React from 'react'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; + +interface PageProps { + params: Promise<{ tenant: string }>; +} + +export default async function TenantClassesPage({ + params }: PageProps) { + const { tenant: tenantSlug } = await params; + + const tenant = await prisma.tenant.findUnique({ + where: { slug: tenantSlug }, + include: { + classSessions: { + orderBy: { startTime: 'asc' }, + include: { + bookings: { + include: { user: true }, + }, + }, + }, + }, + }); + + if (!tenant) { + notFound(); + } + + return ( +
+
+ {/* Navigation Bar */} +
+
+ + ← System Overview + + / + + {tenant.name} + +
+ + + Instructor Portal + → + +
+ + {/* Header */} +
+

Live Virtual Classes

+

+ Browse and join interactive, real-time fitness sessions powered by unbranded WebRTC. +

+
+ + {/* Classes Grid */} +
+ {tenant.classSessions.length === 0 ? ( +
+

No classes currently scheduled for this gym.

+ + Schedule Class in Instructor Portal + +
+ ) : ( +
+ {tenant.classSessions.map((session) => ( +
+
+
+ Virtual Classroom + + {session.bookings.length} / {session.capacity} spots filled + +
+ +

{session.title}

+ {session.description && ( +

{session.description}

+ )} + +
+
Start: {new Date(session.startTime).toLocaleString()}
+
Room: {session.jitsiRoomId}
+
+
+ +
+ + + Join Live Classroom + + + + Host View + +
+
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/[tenant]/classroom/[classId]/page.tsx b/app/[tenant]/classroom/[classId]/page.tsx new file mode 100644 index 0000000..6425204 --- /dev/null +++ b/app/[tenant]/classroom/[classId]/page.tsx @@ -0,0 +1,117 @@ +export const dynamic = 'force-dynamic'; +import React from 'react'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { prisma } from '@/lib/prisma'; +import { generateJitsiToken, JITSI_DOMAIN } from '@/lib/jitsi'; +import JitsiClassroom from '@/components/classroom/JitsiClassroom'; + +interface PageProps { + params: Promise<{ tenant: string; classId: string }>; + searchParams: Promise<{ role?: string; userId?: string }>; +} + +export default async function ClassroomLivePage({ + params, searchParams }: PageProps) { + const { tenant: tenantSlug, classId } = await params; + const { role: queryRole, userId: queryUserId } = await searchParams; + + // 1. Verify tenant & class session + const session = await prisma.classSession.findUnique({ + where: { id: classId }, + include: { + tenant: true, + bookings: { + include: { user: true }, + }, + }, + }); + + if (!session || session.tenant.slug !== tenantSlug) { + notFound(); + } + + // 2. Resolve user & permissions + const isHostRequested = queryRole === 'INSTRUCTOR' || queryRole === 'SUPER_ADMIN'; + + let user = null; + if (queryUserId) { + user = await prisma.user.findUnique({ + where: { id: queryUserId }, + }); + } + + if (!user) { + // Select first appropriate user in the tenant matching requested role + user = await prisma.user.findFirst({ + where: { + tenantId: session.tenantId, + role: isHostRequested ? { in: ['INSTRUCTOR', 'SUPER_ADMIN'] } : 'CLIENT', + }, + }); + } + + // Fallback demo user if tenant has no seeded users yet + const effectiveUser = user || { + id: `guest-${Math.random().toString(36).slice(2, 8)}`, + name: isHostRequested ? 'Head Coach' : 'Fitness Member', + email: `${isHostRequested ? 'coach' : 'member'}@${tenantSlug}.local`, + role: (isHostRequested ? 'INSTRUCTOR' : 'CLIENT') as 'INSTRUCTOR' | 'CLIENT', + }; + + const isHost = effectiveUser.role === 'INSTRUCTOR' || effectiveUser.role === 'SUPER_ADMIN'; + + // 3. Generate signed Jitsi JWT token for this session + const jwtToken = generateJitsiToken({ + roomName: session.jitsiRoomId, + tenantSlug: session.tenant.slug, + user: { + id: effectiveUser.id, + name: effectiveUser.name || 'Participant', + email: effectiveUser.email, + role: effectiveUser.role, + }, + expiresInSeconds: 7200, + }); + + return ( +
+ {/* Top Banner Navigation */} +
+
+ + ← Exit to Dashboard + + + {session.title} + + + • {session.tenant.name} + +
+ +
+ + Signed in as: {effectiveUser.name} ({effectiveUser.role}) + +
+
+ + {/* Main Jitsi WebRTC Classroom Container */} +
+ +
+
+ ); +} diff --git a/app/[tenant]/instructor/classes/page.tsx b/app/[tenant]/instructor/classes/page.tsx new file mode 100644 index 0000000..0801b34 --- /dev/null +++ b/app/[tenant]/instructor/classes/page.tsx @@ -0,0 +1,253 @@ +export const dynamic = 'force-dynamic'; +import React from 'react'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { revalidatePath } from 'next/cache'; +import { prisma, getTenantPrisma } from '@/lib/prisma'; +import { generateJitsiRoomName } from '@/lib/jitsi'; + +interface PageProps { + params: Promise<{ tenant: string }>; +} + +export default async function InstructorClassesPage({ + params }: PageProps) { + const { tenant: tenantSlug } = await params; + + const tenant = await prisma.tenant.findUnique({ + where: { slug: tenantSlug }, + include: { + users: { + where: { role: { in: ['INSTRUCTOR', 'SUPER_ADMIN'] } }, + }, + classSessions: { + orderBy: { startTime: 'desc' }, + include: { + bookings: { + include: { user: true }, + }, + }, + }, + }, + }); + + if (!tenant) { + notFound(); + } + + // Server Action to schedule class + async function scheduleClassAction(formData: FormData): Promise { + 'use server'; + + const title = formData.get('title') as string; + const description = (formData.get('description') as string) || ''; + const dateStr = formData.get('date') as string; + const timeStr = formData.get('time') as string; + const durationMins = parseInt(formData.get('duration') as string, 10) || 60; + const capacity = parseInt(formData.get('capacity') as string, 10) || 15; + + const start = new Date(`${dateStr}T${timeStr}:00`); + const end = new Date(start.getTime() + durationMins * 60 * 1000); + const timestamp = Date.now(); + const tempId = Math.random().toString(36).slice(2, 7); + const jitsiRoomId = generateJitsiRoomName(tenantSlug, tempId, timestamp); + + const tenantPrisma = getTenantPrisma(tenant!.id); + await tenantPrisma.classSession.create({ + data: { + tenantId: tenant!.id, + title, + description, + startTime: start, + endTime: end, + capacity, + jitsiRoomId, + }, + }); + + revalidatePath(`/${tenantSlug}/instructor/classes`); + revalidatePath(`/${tenantSlug}/classes`); + } + + const defaultDate = new Date().toISOString().split('T')[0]; + const defaultTime = '10:00'; + + return ( +
+
+ {/* Navigation Breadcrumb */} +
+
+ + ← Student Class Schedule + + / + + Instructor Portal + +
+ + Tenant: {tenant.name} ({tenant.slug}) + +
+ + {/* Header */} +
+

Live Classroom Management

+

+ Schedule virtual workout classes, configure WebRTC room parameters, and launch live unbranded sessions. +

+
+ + {/* Schedule New Class Form */} +
+

+ + Schedule New Virtual Class +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ + {/* Existing Classes List */} +
+
+

Classroom Sessions ({tenant.classSessions.length})

+ Deterministic room names with host JWT signing +
+ + {tenant.classSessions.length === 0 ? ( +
+ No classes scheduled yet. Use the form above to create your first live class session. +
+ ) : ( +
+ {tenant.classSessions.map((session) => ( +
+
+
+

{session.title}

+ + Cap: {session.bookings.length} / {session.capacity} + +
+ + {session.description && ( +

{session.description}

+ )} + +
+ Start: {new Date(session.startTime).toLocaleString()} + • + Room: {session.jitsiRoomId} +
+
+ +
+ + + Start Live Class (Host View) + + + + Preview Student View + +
+
+ ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/actions.ts b/app/actions.ts new file mode 100644 index 0000000..54bc9e8 --- /dev/null +++ b/app/actions.ts @@ -0,0 +1,83 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { prisma, getTenantPrisma } from '@/lib/prisma'; + +export async function createDemoTenantAction(formData: FormData): Promise { + const name = (formData.get('name') as string) || 'Iron Fitness Gym'; + const slug = (formData.get('slug') as string) || `gym-${Date.now().toString().slice(-4)}`; + + // 1. Create Tenant + const tenant = await prisma.tenant.create({ + data: { + name, + slug, + domain: `${slug}.fitnessapp.local`, + }, + }); + + // 2. Use Tenant-Scoped Client Extension to create user and class session + const tenantPrisma = getTenantPrisma(tenant.id); + + const instructor = await tenantPrisma.user.create({ + data: { + tenantId: tenant.id, + email: `coach@${slug}.com`, + name: 'Coach Marcus', + role: 'INSTRUCTOR', + }, + }); + + const client = await tenantPrisma.user.create({ + data: { + tenantId: tenant.id, + email: `athlete@${slug}.com`, + name: 'Alex Rivera', + role: 'CLIENT', + }, + }); + + const session = await tenantPrisma.classSession.create({ + data: { + tenantId: tenant.id, + title: 'HIIT & Functional Strength', + description: 'High intensity interval training with kettlebells and plyometrics', + startTime: new Date(Date.now() + 24 * 3600 * 1000), + endTime: new Date(Date.now() + 25 * 3600 * 1000), + capacity: 12, + jitsiRoomId: `room-${tenant.id.slice(0, 8)}`, + }, + }); + + await tenantPrisma.booking.create({ + data: { + tenantId: tenant.id, + userId: client.id, + classSessionId: session.id, + status: 'CONFIRMED', + signedWaiverAt: new Date(), + }, + }); + + await tenantPrisma.eWaiver.create({ + data: { + tenantId: tenant.id, + userId: client.id, + parqStatus: 'CLEARED', + signedAt: new Date(), + ipAddress: '127.0.0.1', + }, + }); + + revalidatePath('/'); +} + +export async function clearDemoDataAction(): Promise { + await prisma.eWaiver.deleteMany(); + await prisma.booking.deleteMany(); + await prisma.classSession.deleteMany(); + await prisma.mediaAsset.deleteMany(); + await prisma.user.deleteMany(); + await prisma.tenant.deleteMany(); + revalidatePath('/'); +} \ No newline at end of file diff --git a/app/api/classes/[id]/join/route.ts b/app/api/classes/[id]/join/route.ts new file mode 100644 index 0000000..b8a22f7 --- /dev/null +++ b/app/api/classes/[id]/join/route.ts @@ -0,0 +1,159 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { prisma, getTenantPrisma } from '@/lib/prisma'; +import { + generateJitsiToken, + generateJitsiMeetingUrl, + JITSI_DOMAIN, +} from '@/lib/jitsi'; + +const JoinRequestSchema = z.object({ + userId: z.string().min(1, 'userId is required'), + role: z.enum(['INSTRUCTOR', 'CLIENT', 'SUPER_ADMIN']).optional(), +}); + +export async function POST( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id: classSessionId } = await context.params; + const body = await req.json(); + const validated = JoinRequestSchema.parse(body); + + // 1. Fetch ClassSession with its parent Tenant and existing bookings + const session = await prisma.classSession.findUnique({ + where: { id: classSessionId }, + include: { + tenant: true, + bookings: { + include: { + user: true, + }, + }, + }, + }); + + if (!session) { + return NextResponse.json( + { error: 'Class session not found' }, + { status: 404 } + ); + } + + // 2. Fetch User and verify tenant membership + const user = await prisma.user.findUnique({ + where: { id: validated.userId }, + }); + + if (!user) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ); + } + + if (user.tenantId !== session.tenantId) { + return NextResponse.json( + { error: 'Cross-tenant access forbidden. User does not belong to this tenant.' }, + { status: 403 } + ); + } + + const effectiveRole = validated.role || user.role; + const isInstructor = effectiveRole === 'INSTRUCTOR' || effectiveRole === 'SUPER_ADMIN'; + + // 3. For CLIENT, validate booking or enroll if space is available + let booking = session.bookings.find((b) => b.userId === user.id); + + if (!isInstructor) { + if (!booking) { + // Check capacity + if (session.bookings.length >= session.capacity) { + return NextResponse.json( + { error: 'Class is full. Maximum capacity reached.' }, + { status: 409 } + ); + } + + // Auto-enroll client into booking + const tenantPrisma = getTenantPrisma(session.tenantId); + booking = await tenantPrisma.booking.create({ + data: { + tenantId: session.tenantId, + userId: user.id, + classSessionId: session.id, + status: 'CONFIRMED', + signedWaiverAt: new Date(), + }, + include: { + user: true, + }, + }); + } else if (booking.status === 'CANCELLED') { + return NextResponse.json( + { error: 'Booking for this class has been cancelled.' }, + { status: 403 } + ); + } + } + + // 4. Generate JWT room authentication token + const token = generateJitsiToken({ + roomName: session.jitsiRoomId, + tenantSlug: session.tenant.slug, + user: { + id: user.id, + name: user.name || (isInstructor ? 'Instructor' : 'Athlete'), + email: user.email, + role: effectiveRole, + }, + expiresInSeconds: 7200, // 2 hours + }); + + // 5. Construct full meeting URL + const meetingUrl = generateJitsiMeetingUrl(session.jitsiRoomId, token); + + return NextResponse.json({ + success: true, + roomName: session.jitsiRoomId, + token, + meetingUrl, + domain: JITSI_DOMAIN, + user: { + id: user.id, + name: user.name || 'Participant', + email: user.email, + role: effectiveRole, + isHost: isInstructor, + }, + session: { + id: session.id, + title: session.title, + description: session.description, + startTime: session.startTime, + endTime: session.endTime, + capacity: session.capacity, + currentParticipants: session.bookings.length, + }, + tenant: { + id: session.tenant.id, + name: session.tenant.name, + slug: session.tenant.slug, + }, + }); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation failed', details: error.issues }, + { status: 400 } + ); + } + + console.error('Error joining classroom:', error); + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/classes/route.ts b/app/api/classes/route.ts new file mode 100644 index 0000000..a1276e9 --- /dev/null +++ b/app/api/classes/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { prisma, getTenantPrisma } from '@/lib/prisma'; +import { generateJitsiRoomName } from '@/lib/jitsi'; + +const CreateClassSchema = z.object({ + tenantId: z.string().min(1, 'tenantId is required'), + title: z.string().min(1, 'title is required'), + description: z.string().optional(), + startTime: z.string().or(z.date()), + endTime: z.string().or(z.date()), + capacity: z.number().int().positive().default(20), +}); + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const validated = CreateClassSchema.parse(body); + + // 1. Verify tenant existence + const tenant = await prisma.tenant.findUnique({ + where: { id: validated.tenantId }, + }); + + if (!tenant) { + return NextResponse.json( + { error: 'Tenant not found' }, + { status: 404 } + ); + } + + const start = new Date(validated.startTime); + const end = new Date(validated.endTime); + const timestamp = Date.now(); + + // 2. Generate deterministic tenant-isolated Jitsi room name + const tempSessionId = `temp-${Math.random().toString(36).slice(2, 8)}`; + const jitsiRoomId = generateJitsiRoomName(tenant.slug, tempSessionId, timestamp); + + // 3. Create ClassSession in PostgreSQL via tenant-scoped Prisma client + const tenantPrisma = getTenantPrisma(tenant.id); + const classSession = await tenantPrisma.classSession.create({ + data: { + tenantId: tenant.id, + title: validated.title, + description: validated.description, + startTime: start, + endTime: end, + capacity: validated.capacity, + jitsiRoomId, + }, + }); + + return NextResponse.json( + { + success: true, + classSession, + roomName: classSession.jitsiRoomId, + }, + { status: 201 } + ); + } catch (error: any) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Validation failed', details: error.issues }, + { status: 400 } + ); + } + + console.error('Error creating class session:', error); + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: 500 } + ); + } +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const tenantId = searchParams.get('tenantId'); + + if (!tenantId) { + return NextResponse.json( + { error: 'tenantId query parameter is required' }, + { status: 400 } + ); + } + + const tenantPrisma = getTenantPrisma(tenantId); + const classes = await tenantPrisma.classSession.findMany({ + orderBy: { startTime: 'asc' }, + include: { + bookings: { + include: { + user: true, + }, + }, + }, + }); + + return NextResponse.json({ classes }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Internal server error' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..af98450 Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a2dc41e --- /dev/null +++ b/app/globals.css @@ -0,0 +1,26 @@ +@import "tailwindcss"; + +:root { + --background: #ffffff; + --foreground: #171717; +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0a0a0a; + --foreground: #ededed; + } +} + +body { + background: var(--background); + color: var(--foreground); + font-family: Arial, Helvetica, sans-serif; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..9852c15 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ children }: LayoutProps<"/">) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..24583a6 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,330 @@ +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>`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 ( +
+
+ {/* Header */} +
+
+
+ + Milestone 1 Verified +
+

Fitness App POC & Multi-Tenant Engine

+

+ Next.js 16 (App Router) • Prisma ORM • PostgreSQL • Redis • MinIO S3 +

+
+
+
+ +
+ {tenants.length > 0 && ( +
+ +
+ )} +
+
+ + {/* Live Service Health Grid */} +
+ {/* PostgreSQL Card */} +
+
+ Database + +
+
+

PostgreSQL

+

{getServiceAddress(process.env.DATABASE_URL)}

+

+ {pgStatus.message} +

+
+
+ + {/* Redis Card */} +
+
+ Cache / Queue + +
+
+

Redis 7

+

{getServiceAddress(process.env.REDIS_URL)}

+

+ {redisStatus.message} +

+
+
+ + {/* MinIO S3 Card */} +
+
+ Object Storage + +
+
+

MinIO S3

+

{getServiceAddress(process.env.S3_ENDPOINT)}

+

+ {s3Status.message} +

+
+
+ + {/* Multi-Tenant Extension Card */} +
+
+ Prisma Client + +
+
+

$extends Filter

+

lib/prisma.ts

+

+ tenantId auto-scoping active +

+
+
+
+ + {/* Database Metric Stats */} +
+

+ Live Schema Metrics +

+
+
+
{tenants.length}
+
Tenants (Gyms)
+
+
+
{userCount}
+
Users
+
+
+
{sessionCount}
+
Class Sessions
+
+
+
{bookingCount}
+
Bookings
+
+
+
{waiverCount}
+
E-Waivers
+
+
+
+ + {/* Tenants Section */} +
+
+

Registered Tenants & Scoped Records

+ Showing recent tenants +
+ + {tenants.length === 0 ? ( +
+

No tenants registered in the database yet.

+

+ Click "+ Seed Demo Tenant" in the header to create an isolated gym tenant with sample users and classes. +

+
+ ) : ( +
+ {tenants.map((t) => ( +
+
+
+

{t.name}

+
+ slug: {t.slug} + • + domain: {t.domain ?? 'n/a'} + • + ID: {t.id} +
+
+
+ + {t.users.length} Users + + + {t.classSessions.length} Classes + + + {t.bookings.length} Bookings + +
+
+ + {/* Users breakdown */} +
+
+ Tenant Users: +
+ {t.users.map((u) => ( +
+ {u.name} ({u.email}) + + {u.role} + +
+ ))} +
+
+ +
+ Class Sessions: +
+ {t.classSessions.map((cs) => ( +
+ {cs.title} + Cap: {cs.capacity} +
+ ))} +
+
+
+ +
+
+ Live Virtual Classroom: +
+
+ + + Student Schedule & Live Room → + + + Instructor Portal → + +
+
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/components/classroom/JitsiClassroom.tsx b/components/classroom/JitsiClassroom.tsx new file mode 100644 index 0000000..fb0329e --- /dev/null +++ b/components/classroom/JitsiClassroom.tsx @@ -0,0 +1,227 @@ +'use client'; + +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import Script from 'next/script'; +import { useRouter } from 'next/navigation'; +import { + DEFAULT_JITSI_CONFIG_OVERRIDES, + DEFAULT_JITSI_INTERFACE_OVERRIDES, +} from '@/lib/jitsi'; + +declare global { + interface Window { + JitsiMeetExternalAPI: any; + } +} + +export interface JitsiClassroomProps { + roomName: string; + tenantSlug: string; + jwtToken?: string; + domain?: string; + displayName?: string; + email?: string; + avatarUrl?: string; + isHost?: boolean; + onJoined?: (event: any) => void; + onLeft?: () => void; +} + +export default function JitsiClassroom({ + roomName, + tenantSlug, + jwtToken, + domain = 'jitsi.my127001.ca', + displayName = 'Participant', + email, + avatarUrl, + isHost = false, + onJoined, + onLeft, +}: JitsiClassroomProps) { + const router = useRouter(); + const containerRef = useRef(null); + const jitsiApiRef = useRef(null); + const [scriptLoaded, setScriptLoaded] = useState(false); + const [scriptError, setScriptError] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const [participantsCount, setParticipantsCount] = useState(1); + + const handleExit = useCallback(() => { + if (jitsiApiRef.current) { + try { + jitsiApiRef.current.dispose(); + } catch (e) { + console.error('Error disposing Jitsi API:', e); + } + jitsiApiRef.current = null; + } + onLeft?.(); + router.push(`/${tenantSlug}/classes`); + }, [router, tenantSlug, onLeft]); + + const initJitsi = useCallback(() => { + if (!window.JitsiMeetExternalAPI || !containerRef.current || jitsiApiRef.current) { + return; + } + + try { + // Clear container before mounting + containerRef.current.innerHTML = ''; + + const options = { + roomName, + parentNode: containerRef.current, + width: '100%', + height: '100%', + jwt: jwtToken, + userInfo: { + displayName, + email, + }, + configOverwrite: { + ...DEFAULT_JITSI_CONFIG_OVERRIDES, + // Host vs Participant controls + startWithAudioMuted: !isHost, + startWithVideoMuted: false, + }, + interfaceConfigOverwrite: { + ...DEFAULT_JITSI_INTERFACE_OVERRIDES, + }, + }; + + const api = new window.JitsiMeetExternalAPI(domain, options); + jitsiApiRef.current = api; + + // Event Listeners + api.addEventListener('videoConferenceJoined', (event: any) => { + setIsConnected(true); + onJoined?.(event); + }); + + api.addEventListener('videoConferenceLeft', () => { + setIsConnected(false); + handleExit(); + }); + + api.addEventListener('participantJoined', (event: any) => { + setParticipantsCount((prev) => prev + 1); + }); + + api.addEventListener('participantLeft', () => { + setParticipantsCount((prev) => Math.max(1, prev - 1)); + }); + + api.addEventListener('readyToClose', () => { + handleExit(); + }); + + // Set avatar if provided + if (avatarUrl) { + api.executeCommand('avatarUrl', avatarUrl); + } + } catch (err) { + console.error('Failed to initialize JitsiMeetExternalAPI:', err); + setScriptError(true); + } + }, [ + roomName, + jwtToken, + domain, + displayName, + email, + avatarUrl, + isHost, + onJoined, + handleExit, + ]); + + useEffect(() => { + if (scriptLoaded) { + initJitsi(); + } + + return () => { + if (jitsiApiRef.current) { + try { + jitsiApiRef.current.dispose(); + } catch { + // ignore cleanup errors + } + jitsiApiRef.current = null; + } + }; + }, [scriptLoaded, initJitsi]); + + return ( +
+ {/* External Jitsi Meet API Script */} +