Import fitness app with classroom layout and service health fixes

This commit is contained in:
Albert Blais 2026-10-11 12:16:20 -04:00
commit 491e2f54af
35 changed files with 11971 additions and 0 deletions

11
.env.example Normal file
View File

@ -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=

51
.gitignore vendored Normal file
View File

@ -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

9
AGENTS.md Normal file
View File

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## 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.
<!-- END:nextjs-agent-rules -->

48
README.md Normal file
View File

@ -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.

View File

@ -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 (
<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">
{/* Navigation Bar */}
<div className="flex items-center justify-between border-b border-neutral-800 pb-4">
<div className="flex items-center gap-3">
<Link
href="/"
className="text-xs text-neutral-400 hover:text-white transition"
>
← System Overview
</Link>
<span className="text-neutral-600">/</span>
<span className="text-xs text-emerald-400 font-semibold uppercase tracking-wider">
{tenant.name}
</span>
</div>
<Link
href={`/${tenantSlug}/instructor/classes`}
className="text-xs px-3 py-1.5 rounded-lg bg-neutral-900 hover:bg-neutral-800 text-neutral-300 border border-neutral-800 transition flex items-center gap-1.5"
>
<span>Instructor Portal</span>
<span>→</span>
</Link>
</div>
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight text-white">Live Virtual Classes</h1>
<p className="text-sm text-neutral-400 mt-1">
Browse and join interactive, real-time fitness sessions powered by unbranded WebRTC.
</p>
</div>
{/* Classes Grid */}
<div className="space-y-4">
{tenant.classSessions.length === 0 ? (
<div className="bg-neutral-900 border border-dashed border-neutral-800 rounded-xl p-12 text-center">
<p className="text-sm text-neutral-400">No classes currently scheduled for this gym.</p>
<Link
href={`/${tenantSlug}/instructor/classes`}
className="mt-3 inline-block px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg transition"
>
Schedule Class in Instructor Portal
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{tenant.classSessions.map((session) => (
<div
key={session.id}
className="bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col justify-between hover:border-neutral-700 transition"
>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-emerald-400 font-medium">Virtual Classroom</span>
<span className="text-[11px] font-mono text-neutral-500 bg-neutral-950 px-2 py-0.5 rounded border border-neutral-800">
{session.bookings.length} / {session.capacity} spots filled
</span>
</div>
<h3 className="text-lg font-bold text-white">{session.title}</h3>
{session.description && (
<p className="text-xs text-neutral-400 line-clamp-2">{session.description}</p>
)}
<div className="pt-2 text-xs text-neutral-500 font-mono space-y-0.5">
<div>Start: {new Date(session.startTime).toLocaleString()}</div>
<div>Room: {session.jitsiRoomId}</div>
</div>
</div>
<div className="mt-6 pt-4 border-t border-neutral-800/80 flex items-center justify-between gap-3">
<Link
href={`/${tenantSlug}/classroom/${session.id}?role=CLIENT`}
className="flex-1 text-center py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg shadow transition flex items-center justify-center gap-1.5"
>
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
Join Live Classroom
</Link>
<Link
href={`/${tenantSlug}/classroom/${session.id}?role=INSTRUCTOR`}
className="px-3 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-xs font-medium rounded-lg transition"
title="Enter as Instructor / Host"
>
Host View
</Link>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="h-screen h-dvh w-full min-h-0 min-w-0 overflow-hidden bg-neutral-950 text-neutral-100 font-sans p-2 sm:p-4 md:p-6 flex flex-col">
{/* Top Banner Navigation */}
<div className="max-w-7xl w-full min-w-0 mx-auto mb-2 sm:mb-4 shrink-0 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-3">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<Link
href={`/${tenantSlug}/classes`}
className="shrink-0 text-xs text-neutral-400 hover:text-white transition px-2.5 py-1 rounded bg-neutral-900 border border-neutral-800"
>
← Exit to Dashboard
</Link>
<span className="min-w-0 text-sm font-semibold text-white truncate" title={session.title}>
{session.title}
</span>
<span className="hidden md:inline min-w-0 truncate text-xs font-mono text-neutral-500" title={session.tenant.name}>
• {session.tenant.name}
</span>
</div>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-xs text-neutral-400">
Signed in as: <strong className="text-white">{effectiveUser.name}</strong> ({effectiveUser.role})
</span>
</div>
</div>
{/* Main Jitsi WebRTC Classroom Container */}
<div className="max-w-7xl w-full min-h-0 min-w-0 mx-auto flex-1 flex flex-col">
<JitsiClassroom
roomName={session.jitsiRoomId}
tenantSlug={tenantSlug}
jwtToken={jwtToken}
domain={JITSI_DOMAIN}
displayName={effectiveUser.name || 'Participant'}
email={effectiveUser.email}
isHost={isHost}
/>
</div>
</div>
);
}

View File

@ -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<void> {
'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 (
<div className="min-h-screen bg-neutral-950 text-neutral-100 p-6 md:p-12 font-sans">
<div className="max-w-6xl mx-auto space-y-8">
{/* Navigation Breadcrumb */}
<div className="flex items-center justify-between border-b border-neutral-800 pb-4">
<div className="flex items-center gap-3">
<Link
href={`/${tenantSlug}/classes`}
className="text-xs text-neutral-400 hover:text-white transition"
>
← Student Class Schedule
</Link>
<span className="text-neutral-600">/</span>
<span className="text-xs text-emerald-400 font-semibold uppercase tracking-wider">
Instructor Portal
</span>
</div>
<span className="text-xs font-mono text-neutral-500 bg-neutral-900 px-2.5 py-1 rounded border border-neutral-800">
Tenant: {tenant.name} ({tenant.slug})
</span>
</div>
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight text-white">Live Classroom Management</h1>
<p className="text-sm text-neutral-400 mt-1">
Schedule virtual workout classes, configure WebRTC room parameters, and launch live unbranded sessions.
</p>
</div>
{/* Schedule New Class Form */}
<section className="bg-neutral-900/90 border border-neutral-800 rounded-xl p-6 shadow-xl">
<h2 className="text-base font-semibold text-white mb-4 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-400" />
Schedule New Virtual Class
</h2>
<form action={scheduleClassAction} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="lg:col-span-2">
<label className="block text-xs font-medium text-neutral-300 mb-1">Class Title</label>
<input
type="text"
name="title"
required
placeholder="e.g. HIIT Strength & Conditioning"
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white placeholder-neutral-600 focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Date</label>
<input
type="date"
name="date"
required
defaultValue={defaultDate}
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Start Time</label>
<input
type="time"
name="time"
required
defaultValue={defaultTime}
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-emerald-500"
/>
</div>
<div className="lg:col-span-2">
<label className="block text-xs font-medium text-neutral-300 mb-1">Description (Optional)</label>
<input
type="text"
name="description"
placeholder="Equipment needed: dumbbells, resistance band"
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white placeholder-neutral-600 focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Duration (Minutes)</label>
<input
type="number"
name="duration"
defaultValue={45}
min={15}
max={180}
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-300 mb-1">Capacity</label>
<input
type="number"
name="capacity"
defaultValue={15}
min={1}
max={100}
className="w-full bg-neutral-950 border border-neutral-800 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-emerald-500"
/>
</div>
<div className="md:col-span-2 lg:col-span-4 flex justify-end mt-2">
<button
type="submit"
className="px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold rounded-lg shadow transition flex items-center gap-2"
>
<span>Schedule & Generate Room</span>
<span>→</span>
</button>
</div>
</form>
</section>
{/* Existing Classes List */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Classroom Sessions ({tenant.classSessions.length})</h2>
<span className="text-xs text-neutral-400">Deterministic room names with host JWT signing</span>
</div>
{tenant.classSessions.length === 0 ? (
<div className="bg-neutral-900 border border-dashed border-neutral-800 rounded-xl p-10 text-center text-neutral-400 text-sm">
No classes scheduled yet. Use the form above to create your first live class session.
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{tenant.classSessions.map((session) => (
<div
key={session.id}
className="bg-neutral-900 border border-neutral-800 rounded-xl p-5 hover:border-neutral-700 transition flex flex-col md:flex-row md:items-center justify-between gap-4"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-white">{session.title}</h3>
<span className="px-2 py-0.5 rounded text-[10px] bg-neutral-800 text-neutral-400 font-mono">
Cap: {session.bookings.length} / {session.capacity}
</span>
</div>
{session.description && (
<p className="text-xs text-neutral-400">{session.description}</p>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-neutral-500 font-mono pt-1">
<span>Start: {new Date(session.startTime).toLocaleString()}</span>
<span>•</span>
<span>Room: {session.jitsiRoomId}</span>
</div>
</div>
<div className="flex items-center gap-3">
<Link
href={`/${tenantSlug}/classroom/${session.id}?role=INSTRUCTOR`}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg shadow transition flex items-center gap-1.5 whitespace-nowrap"
>
<span className="w-2 h-2 rounded-full bg-white animate-pulse" />
Start Live Class (Host View)
</Link>
<Link
href={`/${tenantSlug}/classroom/${session.id}?role=CLIENT`}
className="px-3 py-2 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 text-xs font-medium rounded-lg transition whitespace-nowrap"
>
Preview Student View
</Link>
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}

83
app/actions.ts Normal file
View File

@ -0,0 +1,83 @@
'use server';
import { revalidatePath } from 'next/cache';
import { prisma, getTenantPrisma } from '@/lib/prisma';
export async function createDemoTenantAction(formData: FormData): Promise<void> {
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<void> {
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('/');
}

View File

@ -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 }
);
}
}

109
app/api/classes/route.ts Normal file
View File

@ -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 }
);
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

26
app/globals.css Normal file
View File

@ -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;
}

29
app/layout.tsx Normal file
View File

@ -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 (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

330
app/page.tsx Normal file
View File

@ -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<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>
);
}

View File

@ -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<HTMLDivElement>(null);
const jitsiApiRef = useRef<any>(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 (
<div className="relative w-full h-full min-h-0 min-w-0 flex-1 flex flex-col bg-[#121212] rounded-xl overflow-hidden border border-neutral-800 shadow-2xl">
{/* External Jitsi Meet API Script */}
<Script
src={`https://${domain}/external_api.js`}
strategy="afterInteractive"
onReady={() => setScriptLoaded(true)}
onError={() => {
console.warn(`Could not load external_api.js from https://${domain}, activating iframe fallback mode`);
setScriptError(true);
}}
/>
{/* Classroom Status Top Bar */}
<div className="shrink-0 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 sm:px-4 py-2.5 bg-neutral-900 border-b border-neutral-800 text-xs">
<div className="flex min-w-0 flex-[1_1_16rem] items-center gap-2 sm:gap-3">
<span className="shrink-0 flex items-center gap-1.5 font-medium text-neutral-200">
<span
className={`w-2.5 h-2.5 rounded-full ${
isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-amber-400'
}`}
/>
{isConnected ? 'LIVE CLASSROOM' : 'CONNECTING...'}
</span>
<span className="hidden sm:inline text-neutral-500">|</span>
<span className="hidden sm:block min-w-0 truncate text-neutral-400 font-mono" title={roomName}>Room: {roomName}</span>
{isHost && (
<span className="shrink-0 px-2 py-0.5 rounded text-[10px] bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold">
HOST / INSTRUCTOR
</span>
)}
</div>
<div className="shrink-0 flex items-center gap-3">
<span className="hidden sm:inline text-neutral-400">
{participantsCount} participant{participantsCount !== 1 ? 's' : ''}
</span>
<button
onClick={handleExit}
className="px-3 py-1 bg-red-600/80 hover:bg-red-600 text-white font-medium rounded transition"
>
Leave Class
</button>
</div>
</div>
{/* Primary Video Container */}
<div className="relative flex-1 min-h-0 min-w-0 w-full overflow-hidden bg-[#121212]">
{/* Loading overlay when waiting for API */}
{!scriptLoaded && !scriptError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#121212] z-10 text-neutral-300">
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin mb-3"></div>
<p className="text-sm font-medium">Initializing secure live classroom...</p>
<p className="text-xs text-neutral-500 mt-1">Connecting to {domain}</p>
</div>
)}
{/* Script Fallback Direct Iframe Mode */}
{scriptError ? (
<iframe
src={`https://${domain}/${encodeURIComponent(roomName)}${jwtToken ? `?jwt=${encodeURIComponent(jwtToken)}` : ''}#config.prejoinPageEnabled=false&config.disableThirdPartyRequests=true`}
allow="camera; microphone; display-capture; autoplay; clipboard-write"
className="absolute inset-0 block w-full h-full border-0"
title="Jitsi Classroom Live Session"
/>
) : (
<div ref={containerRef} className="absolute inset-0 w-full h-full" />
)}
</div>
</div>
);
}

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

139
lib/jitsi.ts Normal file
View File

@ -0,0 +1,139 @@
import jwt from 'jsonwebtoken';
export interface JitsiUser {
id: string;
name: string;
email?: string;
avatar?: string;
role: 'INSTRUCTOR' | 'CLIENT' | 'SUPER_ADMIN';
}
export interface JitsiRoomOptions {
tenantSlug: string;
classSessionId: string;
timestamp?: number;
}
export interface JitsiTokenOptions {
roomName: string;
tenantSlug: string;
user: JitsiUser;
expiresInSeconds?: number;
}
const JITSI_DOMAIN = process.env.JITSI_DOMAIN || 'jitsi.my127001.ca';
const JITSI_APP_ID = process.env.JITSI_APP_ID || 'fitness-app';
const JITSI_PUBLIC_URL = process.env.JITSI_PUBLIC_URL || `https://${JITSI_DOMAIN}`;
/**
* 1. Generates deterministic, tenant-isolated Jitsi room name:
* Format: ${tenantSlug}_${classSessionId}_${timestamp}
*/
export function generateJitsiRoomName(
tenantSlug: string,
classSessionId: string,
timestamp: number = Date.now()
): string {
const cleanSlug = tenantSlug.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
const cleanSessionId = classSessionId.replace(/[^a-z0-9_-]/g, '-');
return `${cleanSlug}_${cleanSessionId}_${timestamp}`;
}
/**
* 2. Generates a signed Jitsi JWT token for host (INSTRUCTOR) or participant (CLIENT)
*/
export function generateJitsiToken({
roomName,
tenantSlug,
user,
expiresInSeconds = 7200, // 2 hours
}: JitsiTokenOptions): string {
const secret = process.env.JITSI_APP_SECRET;
if (!secret) {
throw new Error('JITSI_APP_SECRET is required to sign classroom access tokens');
}
const isHost = user.role === 'INSTRUCTOR' || user.role === 'SUPER_ADMIN';
const nowInSeconds = Math.floor(Date.now() / 1000);
const payload = {
aud: 'jitsi',
iss: JITSI_APP_ID,
sub: JITSI_DOMAIN,
room: roomName,
iat: nowInSeconds,
nbf: nowInSeconds - 5,
exp: nowInSeconds + expiresInSeconds,
context: {
user: {
id: user.id,
name: user.name,
email: user.email || `${user.id}@${tenantSlug}.local`,
avatar: user.avatar,
moderator: isHost,
},
features: {
recording: isHost,
livestreaming: isHost,
screenSharing: true,
lobby: isHost,
},
tenant: tenantSlug,
},
};
return jwt.sign(payload, secret, {
algorithm: 'HS256',
header: {
typ: 'JWT',
alg: 'HS256',
},
});
}
/**
* 3. Constructs the meeting room URL with unbranded options
*/
export function generateJitsiMeetingUrl(roomName: string, token?: string): string {
const base = `${JITSI_PUBLIC_URL}/${encodeURIComponent(roomName)}`;
if (!token) return base;
return `${base}?jwt=${encodeURIComponent(token)}`;
}
/**
* Pre-set feature flags and config overrides for unbranded classrooms
*/
export const DEFAULT_JITSI_CONFIG_OVERRIDES = {
disableThirdPartyRequests: true,
defaultLanguage: 'en',
hideLobbyButton: false,
toolbarButtons: [
'microphone',
'camera',
'desktop',
'chat',
'raisehand',
'tileview',
'hangup',
],
disableDeepLinking: true,
prejoinPageEnabled: false,
startWithAudioMuted: false,
startWithVideoMuted: false,
enableWelcomePage: false,
enableClosePage: false,
disableInviteFunctions: true,
};
export const DEFAULT_JITSI_INTERFACE_OVERRIDES = {
SHOW_JITSI_WATERMARK: false,
SHOW_WATERMARK_FOR_GUESTS: false,
SHOW_BRAND_WATERMARK: false,
DEFAULT_BACKGROUND: '#121212',
SHOW_PROMOTIONAL_CLOSE_PAGE: false,
MOBILE_APP_PROMO: false,
HIDE_DEEP_LINKING_LOGO: true,
TOOLBAR_ALWAYS_VISIBLE: false,
};
export { JITSI_DOMAIN, JITSI_PUBLIC_URL, JITSI_APP_ID };

119
lib/prisma.ts Normal file
View File

@ -0,0 +1,119 @@
import { Prisma, PrismaClient } from '@prisma/client';
import { AsyncLocalStorage } from 'async_hooks';
// AsyncLocalStorage to support automatic ambient tenant isolation
export const tenantContext = new AsyncLocalStorage<string>();
const globalForPrisma = globalThis as unknown as {
prisma: ReturnType<typeof createPrismaClient> | undefined;
};
// Models with tenantId field
const TENANT_MODELS = new Set(['User', 'ClassSession', 'Booking', 'MediaAsset', 'EWaiver']);
export function createTenantExtension(explicitTenantId?: string) {
return Prisma.defineExtension({
name: 'multiTenantExtension',
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
const currentTenantId = explicitTenantId || tenantContext.getStore();
if (!currentTenantId || !TENANT_MODELS.has(model)) {
return query(args);
}
const queryArgs = ((args ?? {}) as Record<string, any>);
switch (operation) {
case 'findUnique':
case 'findUniqueOrThrow':
case 'findFirst':
case 'findFirstOrThrow':
case 'findMany':
case 'count':
case 'aggregate':
case 'groupBy': {
queryArgs.where = {
...queryArgs.where,
tenantId: currentTenantId,
};
return query(queryArgs);
}
case 'create': {
queryArgs.data = {
...queryArgs.data,
tenantId: currentTenantId,
};
return query(queryArgs);
}
case 'createMany':
case 'createManyAndReturn': {
if (Array.isArray(queryArgs.data)) {
queryArgs.data = queryArgs.data.map((item: any) => ({
...item,
tenantId: currentTenantId,
}));
} else if (queryArgs.data) {
queryArgs.data = {
...queryArgs.data,
tenantId: currentTenantId,
};
}
return query(queryArgs);
}
case 'update':
case 'updateMany':
case 'delete':
case 'deleteMany':
case 'upsert': {
queryArgs.where = {
...queryArgs.where,
tenantId: currentTenantId,
};
if (operation === 'upsert' && queryArgs.create) {
queryArgs.create = {
...queryArgs.create,
tenantId: currentTenantId,
};
}
return query(queryArgs);
}
default:
return query(args);
}
},
},
},
});
}
export function createPrismaClient() {
const baseClient = new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
return baseClient.$extends(createTenantExtension());
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export type ExtendedPrismaClient = typeof prisma;
/**
* Returns a tenant-scoped Prisma client using Prisma Client Extension
*/
export function getTenantPrisma(tenantId: string) {
const baseClient = new PrismaClient();
return baseClient.$extends(createTenantExtension(tenantId));
}
export default prisma;

11
lib/service-address.ts Normal file
View File

@ -0,0 +1,11 @@
/** Display the configured endpoint without usernames, passwords, or URL queries. */
export function getServiceAddress(value: string | undefined): string {
if (!value) return 'Not configured';
try {
const url = new URL(value);
return url.host || 'Invalid address';
} catch {
return 'Invalid address';
}
}

14
next.config.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
turbopack: {
rules: {
"*.css": {
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;

8804
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "fitness-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test:connections": "tsx scripts/test-connections.ts",
"test:jitsi": "tsx scripts/test-jitsi-integration.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1148.0",
"@prisma/client": "^6.19.3",
"bullmq": "^6.3.12",
"dotenv": "^18.0.7",
"ioredis": "^6.0.0",
"jsonwebtoken": "^9.0.3",
"next": "16.4.0",
"next-auth": "^4.24.15",
"react": "19.3.0",
"react-dom": "19.3.0",
"zod": "^4.6.5"
},
"devDependencies": {
"@tailwindcss/turbopack": "^4",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.4.0",
"prisma": "^6.19.3",
"tailwindcss": "^4",
"tsx": "^4.23.15",
"typescript": "^5"
}
}

View File

@ -0,0 +1,152 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('SUPER_ADMIN', 'INSTRUCTOR', 'CLIENT');
-- CreateEnum
CREATE TYPE "MediaStatus" AS ENUM ('PROCESSING', 'READY');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('CONFIRMED', 'CANCELLED', 'ATTENDED');
-- CreateTable
CREATE TABLE "tenants" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"domain" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"role" "Role" NOT NULL DEFAULT 'CLIENT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "class_sessions" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"startTime" TIMESTAMP(3) NOT NULL,
"endTime" TIMESTAMP(3) NOT NULL,
"capacity" INTEGER NOT NULL,
"jitsiRoomId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "class_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bookings" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"classSessionId" TEXT NOT NULL,
"status" "BookingStatus" NOT NULL DEFAULT 'CONFIRMED',
"signedWaiverAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "bookings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "media_assets" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"s3Key" TEXT NOT NULL,
"status" "MediaStatus" NOT NULL DEFAULT 'PROCESSING',
"rawUrl" TEXT,
"transcodedUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "media_assets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "e_waivers" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"parqStatus" TEXT NOT NULL,
"signedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ipAddress" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "e_waivers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tenants_slug_key" ON "tenants"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "tenants_domain_key" ON "tenants"("domain");
-- CreateIndex
CREATE INDEX "users_tenantId_idx" ON "users"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "users_tenantId_email_key" ON "users"("tenantId", "email");
-- CreateIndex
CREATE INDEX "class_sessions_tenantId_idx" ON "class_sessions"("tenantId");
-- CreateIndex
CREATE INDEX "bookings_tenantId_idx" ON "bookings"("tenantId");
-- CreateIndex
CREATE INDEX "bookings_userId_idx" ON "bookings"("userId");
-- CreateIndex
CREATE INDEX "bookings_classSessionId_idx" ON "bookings"("classSessionId");
-- CreateIndex
CREATE UNIQUE INDEX "bookings_userId_classSessionId_key" ON "bookings"("userId", "classSessionId");
-- CreateIndex
CREATE INDEX "media_assets_tenantId_idx" ON "media_assets"("tenantId");
-- CreateIndex
CREATE INDEX "e_waivers_tenantId_idx" ON "e_waivers"("tenantId");
-- CreateIndex
CREATE INDEX "e_waivers_userId_idx" ON "e_waivers"("userId");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "class_sessions" ADD CONSTRAINT "class_sessions_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bookings" ADD CONSTRAINT "bookings_classSessionId_fkey" FOREIGN KEY ("classSessionId") REFERENCES "class_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "media_assets" ADD CONSTRAINT "media_assets_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "e_waivers" ADD CONSTRAINT "e_waivers_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "e_waivers" ADD CONSTRAINT "e_waivers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

135
prisma/schema.prisma Normal file
View File

@ -0,0 +1,135 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
enum Role {
SUPER_ADMIN
INSTRUCTOR
CLIENT
}
enum MediaStatus {
PROCESSING
READY
}
enum BookingStatus {
CONFIRMED
CANCELLED
ATTENDED
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
domain String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
classSessions ClassSession[]
bookings Booking[]
mediaAssets MediaAsset[]
eWaivers EWaiver[]
@@map("tenants")
}
model User {
id String @id @default(cuid())
tenantId String
email String
name String?
role Role @default(CLIENT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
bookings Booking[]
eWaivers EWaiver[]
@@unique([tenantId, email])
@@index([tenantId])
@@map("users")
}
model ClassSession {
id String @id @default(cuid())
tenantId String
title String
description String?
startTime DateTime
endTime DateTime
capacity Int
jitsiRoomId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
bookings Booking[]
@@index([tenantId])
@@map("class_sessions")
}
model Booking {
id String @id @default(cuid())
tenantId String
userId String
classSessionId String
status BookingStatus @default(CONFIRMED)
signedWaiverAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
classSession ClassSession @relation(fields: [classSessionId], references: [id], onDelete: Cascade)
@@unique([userId, classSessionId])
@@index([tenantId])
@@index([userId])
@@index([classSessionId])
@@map("bookings")
}
model MediaAsset {
id String @id @default(cuid())
tenantId String
title String
s3Key String
status MediaStatus @default(PROCESSING)
rawUrl String?
transcodedUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@map("media_assets")
}
model EWaiver {
id String @id @default(cuid())
tenantId String
userId String
parqStatus String
signedAt DateTime @default(now())
ipAddress String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([userId])
@@map("e_waivers")
}

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

187
scripts/test-connections.ts Normal file
View File

@ -0,0 +1,187 @@
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
dotenv.config({ path: '.env' });
import { PrismaClient } from '@prisma/client';
import Redis from 'ioredis';
import { S3Client, ListBucketsCommand } from '@aws-sdk/client-s3';
import { getTenantPrisma, tenantContext } from '../lib/prisma';
// ANSI color codes for rich formatted terminal output
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const CYAN = '\x1b[36m';
const BOLD = '\x1b[1m';
const RESET = '\x1b[0m';
async function testPostgreSQL() {
console.log(`\n${CYAN}${BOLD}[1/4] Testing PostgreSQL Connection & Query Execution...${RESET}`);
const prisma = new PrismaClient();
try {
const rawResult = await prisma.$queryRaw<Array<{ status: number }>>`SELECT 1 as status;`;
if (!rawResult || rawResult.length === 0 || rawResult[0].status !== 1) {
throw new Error('Raw query SELECT 1 failed to return expected result');
}
console.log(` ${GREEN}✔ Raw query executed successfully: SELECT 1 -> ${rawResult[0].status}${RESET}`);
// Verify Prisma schema models exist
const tenantCount = await prisma.tenant.count();
console.log(` ${GREEN}✔ Schema model verification passed: 'tenants' table accessible (current count: ${tenantCount})${RESET}`);
console.log(` ${GREEN}${BOLD}✔ PostgreSQL check: PASSED${RESET}`);
return true;
} catch (error: any) {
console.error(` ${RED}✘ PostgreSQL check failed: ${error.message}${RESET}`);
throw error;
} finally {
await prisma.$disconnect();
}
}
async function testRedis() {
console.log(`\n${CYAN}${BOLD}[2/4] Testing Redis Connection & Ping/Pong...${RESET}`);
const redisUrl = process.env.REDIS_URL;
if (!redisUrl) {
throw new Error('REDIS_URL environment variable is missing');
}
const redis = new Redis(redisUrl, {
lazyConnect: true,
maxRetriesPerRequest: 1,
connectTimeout: 4000,
commandTimeout: 4000,
retryStrategy: () => null,
});
// Report connection failures through the script's catch block.
redis.on('error', () => {});
try {
const pong = await redis.ping();
if (pong !== 'PONG') {
throw new Error(`Expected PONG, received: ${pong}`);
}
console.log(` ${GREEN}✔ Redis ping returned: ${pong}${RESET}`);
const testKey = 'test:healthcheck:' + Date.now();
await redis.set(testKey, 'ok', 'EX', 10);
const retrievedVal = await redis.get(testKey);
await redis.del(testKey);
if (retrievedVal !== 'ok') {
throw new Error(`Redis read/write test failed: expected 'ok', got '${retrievedVal}'`);
}
console.log(` ${GREEN}✔ Redis read/write key verification passed${RESET}`);
console.log(` ${GREEN}${BOLD}✔ Redis check: PASSED${RESET}`);
return true;
} catch (error: any) {
console.error(` ${RED}✘ Redis check failed: ${error.message}${RESET}`);
throw error;
} finally {
redis.disconnect();
}
}
async function testMinIO() {
console.log(`\n${CYAN}${BOLD}[3/4] Testing MinIO S3 Bucket Connection...${RESET}`);
const endpoint = process.env.S3_ENDPOINT;
const accessKeyId = process.env.S3_ACCESS_KEY;
const secretAccessKey = process.env.S3_SECRET_KEY;
if (!endpoint || !accessKeyId || !secretAccessKey) {
throw new Error('MinIO S3 configuration variables are missing in environment');
}
const s3 = new S3Client({
endpoint,
region: 'us-east-1',
credentials: {
accessKeyId,
secretAccessKey,
},
forcePathStyle: true,
});
try {
const response = await s3.send(new ListBucketsCommand({}));
const bucketNames = (response.Buckets || []).map((b) => b.Name).filter(Boolean);
console.log(` ${GREEN}✔ MinIO S3 authenticated successfully. Endpoint: ${endpoint}${RESET}`);
console.log(` ${GREEN}✔ Buckets listed (${bucketNames.length}): ${bucketNames.length > 0 ? bucketNames.join(', ') : '(none yet)'}${RESET}`);
console.log(` ${GREEN}${BOLD}✔ MinIO S3 check: PASSED${RESET}`);
return true;
} catch (error: any) {
console.error(` ${RED}✘ MinIO S3 check failed: ${error.message}${RESET}`);
throw error;
}
}
async function testTenantExtension() {
console.log(`\n${CYAN}${BOLD}[4/4] Testing Multi-Tenant Prisma Client Extension ($extends)...${RESET}`);
const testTenantId = 'tenant_verification_test_id';
const tenantPrisma = getTenantPrisma(testTenantId);
try {
// Attempting a findMany on User should automatically include tenantId filter in query
const users = await tenantPrisma.user.findMany();
console.log(` ${GREEN}✔ Tenant-scoped query executed successfully: user.findMany() -> ${users.length} results${RESET}`);
// Test ambient tenant context
await tenantContext.run('ambient_tenant_123', async () => {
const ambientPrisma = (await import('../lib/prisma')).prisma;
const classSessions = await ambientPrisma.classSession.findMany();
console.log(` ${GREEN}✔ Ambient tenant context isolation verified: classSession.findMany() -> ${classSessions.length} results${RESET}`);
});
console.log(` ${GREEN}${BOLD}✔ Multi-Tenant Extension check: PASSED${RESET}`);
return true;
} catch (error: any) {
console.error(` ${RED}✘ Multi-tenant extension check failed: ${error.message}${RESET}`);
throw error;
} finally {
await (tenantPrisma as any).$disconnect?.();
}
}
async function main() {
console.log(`${BOLD}====================================================${RESET}`);
console.log(`${BOLD} Milestone 1 Verification: Docker Services & Schema${RESET}`);
console.log(`${BOLD}====================================================${RESET}`);
let allPassed = true;
try {
await testPostgreSQL();
} catch {
allPassed = false;
}
try {
await testRedis();
} catch {
allPassed = false;
}
try {
await testMinIO();
} catch {
allPassed = false;
}
try {
await testTenantExtension();
} catch {
allPassed = false;
}
console.log(`\n${BOLD}====================================================${RESET}`);
if (allPassed) {
console.log(`${GREEN}${BOLD}🎉 ALL MILESTONE 1 VERIFICATION CHECKS PASSED GREEN!${RESET}`);
console.log(`${BOLD}====================================================${RESET}\n`);
process.exit(0);
} else {
console.log(`${RED}${BOLD}❌ SOME VERIFICATION CHECKS FAILED.${RESET}`);
console.log(`${BOLD}====================================================${RESET}\n`);
process.exit(1);
}
}
main();

View File

@ -0,0 +1,362 @@
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
dotenv.config({ path: '.env' });
import jwt from 'jsonwebtoken';
import https from 'https';
import http from 'http';
import net from 'net';
import {
generateJitsiRoomName,
generateJitsiToken,
generateJitsiMeetingUrl,
JitsiUser,
} from '../lib/jitsi';
import { prisma, getTenantPrisma } from '../lib/prisma';
// ANSI color codes
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const CYAN = '\x1b[36m';
const BOLD = '\x1b[1m';
const RESET = '\x1b[0m';
async function testRoomIdGeneration() {
console.log(`\n${CYAN}${BOLD}[1/4] Testing Deterministic Jitsi Room ID Generation...${RESET}`);
const tenantSlug = 'peak-fitness';
const sessionId = 'session_abc123';
const fixedTimestamp = 1775740000000;
const roomName = generateJitsiRoomName(tenantSlug, sessionId, fixedTimestamp);
const expectedFormat = `${tenantSlug}_${sessionId}_${fixedTimestamp}`;
if (roomName !== expectedFormat) {
throw new Error(`Expected room name '${expectedFormat}', got '${roomName}'`);
}
// Test special character sanitization
const messySlug = 'Gym @ Downtown #1!';
const sanitizedRoom = generateJitsiRoomName(messySlug, sessionId, fixedTimestamp);
if (/[@#! ]/.test(sanitizedRoom)) {
throw new Error(`Room name was not properly sanitized: '${sanitizedRoom}'`);
}
console.log(` ${GREEN}✔ Deterministic room format verified: ${roomName}${RESET}`);
console.log(` ${GREEN}✔ Slug sanitization verified: '${messySlug}' -> '${sanitizedRoom}'${RESET}`);
console.log(` ${GREEN}${BOLD}✔ Room ID generation check: PASSED${RESET}`);
return true;
}
async function testJwtSigning() {
console.log(`\n${CYAN}${BOLD}[2/4] Testing Jitsi JWT Room Token Signing & Claims...${RESET}`);
const roomName = 'iron-gym_session_999_1775740000000';
const tenantSlug = 'iron-gym';
const secret = process.env.JITSI_APP_SECRET;
if (!secret) {
throw new Error('JITSI_APP_SECRET is required for JWT verification');
}
// 1. Host (Instructor) Token
const hostUser: JitsiUser = {
id: 'user_instructor_01',
name: 'Marcus Miller',
email: 'marcus@iron-gym.local',
role: 'INSTRUCTOR',
};
const hostToken = generateJitsiToken({
roomName,
tenantSlug,
user: hostUser,
expiresInSeconds: 3600,
});
const decodedHost = jwt.verify(hostToken, secret) as any;
if (decodedHost.room !== roomName) {
throw new Error(`Token room mismatch: expected '${roomName}', got '${decodedHost.room}'`);
}
if (decodedHost.context?.user?.moderator !== true) {
throw new Error('Host token expected moderator: true');
}
if (decodedHost.context?.features?.recording !== true) {
throw new Error('Host token expected recording: true');
}
console.log(` ${GREEN}✔ Host JWT verified: moderator=true, recording=true, room=${decodedHost.room}${RESET}`);
// 2. Participant (Client) Token
const clientUser: JitsiUser = {
id: 'user_client_02',
name: 'Sarah Connor',
email: 'sarah@iron-gym.local',
role: 'CLIENT',
};
const clientToken = generateJitsiToken({
roomName,
tenantSlug,
user: clientUser,
expiresInSeconds: 3600,
});
const decodedClient = jwt.verify(clientToken, secret) as any;
if (decodedClient.context?.user?.moderator !== false) {
throw new Error('Client token expected moderator: false');
}
if (decodedClient.context?.features?.recording !== false) {
throw new Error('Client token expected recording: false');
}
console.log(` ${GREEN}✔ Client JWT verified: moderator=false, recording=false, room=${decodedClient.room}${RESET}`);
// 3. Meeting URL Construction
const meetingUrl = generateJitsiMeetingUrl(roomName, hostToken);
if (!meetingUrl.includes('https://') || !meetingUrl.includes(roomName) || !meetingUrl.includes('jwt=')) {
throw new Error(`Meeting URL construction failed: ${meetingUrl}`);
}
console.log(` ${GREEN}✔ Meeting URL constructed successfully: ${meetingUrl.slice(0, 75)}...${RESET}`);
console.log(` ${GREEN}${BOLD}✔ JWT signing & claims check: PASSED${RESET}`);
return true;
}
async function testJitsiConnectivity() {
console.log(`\n${CYAN}${BOLD}[3/4] Testing Connectivity to Jitsi Web (192.168.16.50)...${RESET}`);
const host = '192.168.16.50';
// 1. TCP socket test to Port 443
const port443Ok = await new Promise<boolean>((resolve) => {
const socket = new net.Socket();
socket.setTimeout(3000);
socket.connect(443, host, () => {
socket.destroy();
resolve(true);
});
socket.on('error', () => resolve(false));
socket.on('timeout', () => {
socket.destroy();
resolve(false);
});
});
if (!port443Ok) {
throw new Error(`TCP connection to ${host}:443 failed`);
}
console.log(` ${GREEN}✔ TCP connection to ${host}:443 successful${RESET}`);
// 2. HTTPS request to Jitsi Web endpoint
const httpsOk = await new Promise<boolean>((resolve, reject) => {
const req = https.request(
{
host,
port: 443,
path: '/',
method: 'GET',
rejectUnauthorized: false,
timeout: 4000,
},
(res) => {
console.log(` ${GREEN}✔ Jitsi Web HTTPS response received: HTTP ${res.statusCode} ${res.statusMessage}${RESET}`);
resolve(true);
}
);
req.on('error', (err) => {
console.log(` ${YELLOW}⚠ HTTPS request error (${err.message}), checking HTTP port 80 fallback...${RESET}`);
http.get(`http://${host}:80/`, (res80) => {
console.log(` ${GREEN}✔ Jitsi Web HTTP:80 response received: HTTP ${res80.statusCode}${RESET}`);
resolve(true);
}).on('error', reject);
});
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
if (!httpsOk) {
throw new Error(`Failed to receive HTTP/HTTPS response from Jitsi Web at ${host}`);
}
console.log(` ${GREEN}${BOLD}✔ Jitsi Web endpoint check: PASSED${RESET}`);
return true;
}
async function testTenantClassroomIntegration() {
console.log(`\n${CYAN}${BOLD}[4/4] Testing Multi-Tenant Classroom Database Flow...${RESET}`);
const testSlug = 'jitsi-test-tenant';
let tenant = await prisma.tenant.findUnique({ where: { slug: testSlug } });
if (!tenant) {
tenant = await prisma.tenant.create({
data: {
name: 'Jitsi Test Gym',
slug: testSlug,
domain: 'jitsi-test.fitnessapp.local',
},
});
}
const tenantPrisma = getTenantPrisma(tenant.id);
// Create class session with Jitsi room ID
const timestamp = Date.now();
const jitsiRoomId = generateJitsiRoomName(tenant.slug, 'class-poc', timestamp);
const session = await tenantPrisma.classSession.create({
data: {
tenantId: tenant.id,
title: 'Milestone 2 Live HIIT Test',
description: 'End-to-end WebRTC classroom verification session',
startTime: new Date(),
endTime: new Date(Date.now() + 3600 * 1000),
capacity: 10,
jitsiRoomId,
},
});
console.log(` ${GREEN}✔ ClassSession created with Jitsi Room ID: ${session.jitsiRoomId}${RESET}`);
// Create Instructor and Client
const instructor = await tenantPrisma.user.upsert({
where: {
tenantId_email: {
tenantId: tenant.id,
email: `instructor@${testSlug}.com`,
},
},
update: {},
create: {
tenantId: tenant.id,
name: 'Lead Trainer',
email: `instructor@${testSlug}.com`,
role: 'INSTRUCTOR',
},
});
const client = await tenantPrisma.user.upsert({
where: {
tenantId_email: {
tenantId: tenant.id,
email: `client@${testSlug}.com`,
},
},
update: {},
create: {
tenantId: tenant.id,
name: 'Test Athlete',
email: `client@${testSlug}.com`,
role: 'CLIENT',
},
});
// Verify Booking Creation
const booking = await tenantPrisma.booking.upsert({
where: {
userId_classSessionId: {
userId: client.id,
classSessionId: session.id,
},
},
update: {},
create: {
tenantId: tenant.id,
userId: client.id,
classSessionId: session.id,
status: 'CONFIRMED',
signedWaiverAt: new Date(),
},
});
console.log(` ${GREEN}✔ Verified Client Booking: user ${client.id} -> session ${session.id} (Status: ${booking.status})${RESET}`);
// Generate tokens for both
const hostToken = generateJitsiToken({
roomName: session.jitsiRoomId,
tenantSlug: tenant.slug,
user: {
id: instructor.id,
name: instructor.name || 'Lead Trainer',
email: instructor.email,
role: instructor.role,
},
});
const clientToken = generateJitsiToken({
roomName: session.jitsiRoomId,
tenantSlug: tenant.slug,
user: {
id: client.id,
name: client.name || 'Test Athlete',
email: client.email,
role: client.role,
},
});
if (!hostToken || !clientToken) {
throw new Error('Token generation failed for database entities');
}
console.log(` ${GREEN}✔ Tenant isolation verified: both host & client tokens signed for ${session.jitsiRoomId}${RESET}`);
console.log(` ${GREEN}${BOLD}✔ Multi-Tenant Classroom Flow check: PASSED${RESET}`);
return true;
}
async function main() {
console.log(`${BOLD}====================================================${RESET}`);
console.log(`${BOLD} Milestone 2 Verification: Jitsi WebRTC Integration${RESET}`);
console.log(`${BOLD}====================================================${RESET}`);
let allPassed = true;
try {
await testRoomIdGeneration();
} catch (err: any) {
console.error(` ${RED}✘ Room ID check failed: ${err.message}${RESET}`);
allPassed = false;
}
try {
await testJwtSigning();
} catch (err: any) {
console.error(` ${RED}✘ JWT signing check failed: ${err.message}${RESET}`);
allPassed = false;
}
try {
await testJitsiConnectivity();
} catch (err: any) {
console.error(` ${RED}✘ Jitsi connectivity check failed: ${err.message}${RESET}`);
allPassed = false;
}
try {
await testTenantClassroomIntegration();
} catch (err: any) {
console.error(` ${RED}✘ Multi-tenant flow check failed: ${err.message}${RESET}`);
allPassed = false;
}
console.log(`\n${BOLD}====================================================${RESET}`);
if (allPassed) {
console.log(`${GREEN}${BOLD}🎉 ALL MILESTONE 2 JITSI VERIFICATION CHECKS PASSED!${RESET}`);
console.log(`${BOLD}====================================================${RESET}\n`);
process.exit(0);
} else {
console.log(`${RED}${BOLD}❌ SOME JITSI VERIFICATION CHECKS FAILED.${RESET}`);
console.log(`${BOLD}====================================================${RESET}\n`);
process.exit(1);
}
}
main();

View File

@ -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<HTMLDivElement>(null);
const jitsiApiRef = useRef<any>(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 (
<div className="relative w-full h-full min-h-0 min-w-0 flex-1 flex flex-col bg-[#121212] rounded-xl overflow-hidden border border-neutral-800 shadow-2xl">
{/* External Jitsi Meet API Script */}
<Script
src={`https://${domain}/external_api.js`}
strategy="afterInteractive"
onReady={() => setScriptLoaded(true)}
onError={() => {
console.warn(`Could not load external_api.js from https://${domain}, activating iframe fallback mode`);
setScriptError(true);
}}
/>
{/* Classroom Status Top Bar */}
<div className="shrink-0 flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 sm:px-4 py-2.5 bg-neutral-900 border-b border-neutral-800 text-xs">
<div className="flex min-w-0 flex-[1_1_16rem] items-center gap-2 sm:gap-3">
<span className="shrink-0 flex items-center gap-1.5 font-medium text-neutral-200">
<span
className={`w-2.5 h-2.5 rounded-full ${
isConnected ? 'bg-emerald-500 animate-pulse' : 'bg-amber-400'
}`}
/>
{isConnected ? 'LIVE CLASSROOM' : 'CONNECTING...'}
</span>
<span className="hidden sm:inline text-neutral-500">|</span>
<span className="hidden sm:block min-w-0 truncate text-neutral-400 font-mono" title={roomName}>Room: {roomName}</span>
{isHost && (
<span className="shrink-0 px-2 py-0.5 rounded text-[10px] bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold">
HOST / INSTRUCTOR
</span>
)}
</div>
<div className="shrink-0 flex items-center gap-3">
<span className="hidden sm:inline text-neutral-400">
{participantsCount} participant{participantsCount !== 1 ? 's' : ''}
</span>
<button
onClick={handleExit}
className="px-3 py-1 bg-red-600/80 hover:bg-red-600 text-white font-medium rounded transition"
>
Leave Class
</button>
</div>
</div>
{/* Primary Video Container */}
<div className="relative flex-1 min-h-0 min-w-0 w-full overflow-hidden bg-[#121212]">
{/* Loading overlay when waiting for API */}
{!scriptLoaded && !scriptError && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#121212] z-10 text-neutral-300">
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin mb-3"></div>
<p className="text-sm font-medium">Initializing secure live classroom...</p>
<p className="text-xs text-neutral-500 mt-1">Connecting to {domain}</p>
</div>
)}
{/* Script Fallback Direct Iframe Mode */}
{scriptError ? (
<iframe
src={`https://${domain}/${encodeURIComponent(roomName)}${jwtToken ? `?jwt=${encodeURIComponent(jwtToken)}` : ''}#config.prejoinPageEnabled=false&config.disableThirdPartyRequests=true`}
allow="camera; microphone; display-capture; autoplay; clipboard-write"
className="absolute inset-0 block w-full h-full border-0"
title="Jitsi Classroom Live Session"
/>
) : (
<div ref={containerRef} className="absolute inset-0 w-full h-full" />
)}
</div>
</div>
);
}

139
src/lib/jitsi.ts Normal file
View File

@ -0,0 +1,139 @@
import jwt from 'jsonwebtoken';
export interface JitsiUser {
id: string;
name: string;
email?: string;
avatar?: string;
role: 'INSTRUCTOR' | 'CLIENT' | 'SUPER_ADMIN';
}
export interface JitsiRoomOptions {
tenantSlug: string;
classSessionId: string;
timestamp?: number;
}
export interface JitsiTokenOptions {
roomName: string;
tenantSlug: string;
user: JitsiUser;
expiresInSeconds?: number;
}
const JITSI_DOMAIN = process.env.JITSI_DOMAIN || 'jitsi.my127001.ca';
const JITSI_APP_ID = process.env.JITSI_APP_ID || 'fitness-app';
const JITSI_PUBLIC_URL = process.env.JITSI_PUBLIC_URL || `https://${JITSI_DOMAIN}`;
/**
* 1. Generates deterministic, tenant-isolated Jitsi room name:
* Format: ${tenantSlug}_${classSessionId}_${timestamp}
*/
export function generateJitsiRoomName(
tenantSlug: string,
classSessionId: string,
timestamp: number = Date.now()
): string {
const cleanSlug = tenantSlug.toLowerCase().replace(/[^a-z0-9_-]/g, '-');
const cleanSessionId = classSessionId.replace(/[^a-z0-9_-]/g, '-');
return `${cleanSlug}_${cleanSessionId}_${timestamp}`;
}
/**
* 2. Generates a signed Jitsi JWT token for host (INSTRUCTOR) or participant (CLIENT)
*/
export function generateJitsiToken({
roomName,
tenantSlug,
user,
expiresInSeconds = 7200, // 2 hours
}: JitsiTokenOptions): string {
const secret = process.env.JITSI_APP_SECRET;
if (!secret) {
throw new Error('JITSI_APP_SECRET is required to sign classroom access tokens');
}
const isHost = user.role === 'INSTRUCTOR' || user.role === 'SUPER_ADMIN';
const nowInSeconds = Math.floor(Date.now() / 1000);
const payload = {
aud: 'jitsi',
iss: JITSI_APP_ID,
sub: JITSI_DOMAIN,
room: roomName,
iat: nowInSeconds,
nbf: nowInSeconds - 5,
exp: nowInSeconds + expiresInSeconds,
context: {
user: {
id: user.id,
name: user.name,
email: user.email || `${user.id}@${tenantSlug}.local`,
avatar: user.avatar,
moderator: isHost,
},
features: {
recording: isHost,
livestreaming: isHost,
screenSharing: true,
lobby: isHost,
},
tenant: tenantSlug,
},
};
return jwt.sign(payload, secret, {
algorithm: 'HS256',
header: {
typ: 'JWT',
alg: 'HS256',
},
});
}
/**
* 3. Constructs the meeting room URL with unbranded options
*/
export function generateJitsiMeetingUrl(roomName: string, token?: string): string {
const base = `${JITSI_PUBLIC_URL}/${encodeURIComponent(roomName)}`;
if (!token) return base;
return `${base}?jwt=${encodeURIComponent(token)}`;
}
/**
* Pre-set feature flags and config overrides for unbranded classrooms
*/
export const DEFAULT_JITSI_CONFIG_OVERRIDES = {
disableThirdPartyRequests: true,
defaultLanguage: 'en',
hideLobbyButton: false,
toolbarButtons: [
'microphone',
'camera',
'desktop',
'chat',
'raisehand',
'tileview',
'hangup',
],
disableDeepLinking: true,
prejoinPageEnabled: false,
startWithAudioMuted: false,
startWithVideoMuted: false,
enableWelcomePage: false,
enableClosePage: false,
disableInviteFunctions: true,
};
export const DEFAULT_JITSI_INTERFACE_OVERRIDES = {
SHOW_JITSI_WATERMARK: false,
SHOW_WATERMARK_FOR_GUESTS: false,
SHOW_BRAND_WATERMARK: false,
DEFAULT_BACKGROUND: '#121212',
SHOW_PROMOTIONAL_CLOSE_PAGE: false,
MOBILE_APP_PROMO: false,
HIDE_DEEP_LINKING_LOGO: true,
TOOLBAR_ALWAYS_VISIBLE: false,
};
export { JITSI_DOMAIN, JITSI_PUBLIC_URL, JITSI_APP_ID };

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}