Import fitness app with classroom layout and service health fixes
This commit is contained in:
commit
491e2f54af
11
.env.example
Normal file
11
.env.example
Normal 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
51
.gitignore
vendored
Normal 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
9
AGENTS.md
Normal 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
48
README.md
Normal 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.
|
||||||
131
app/[tenant]/classes/page.tsx
Normal file
131
app/[tenant]/classes/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
app/[tenant]/classroom/[classId]/page.tsx
Normal file
117
app/[tenant]/classroom/[classId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
253
app/[tenant]/instructor/classes/page.tsx
Normal file
253
app/[tenant]/instructor/classes/page.tsx
Normal 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
83
app/actions.ts
Normal 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('/');
|
||||||
|
}
|
||||||
159
app/api/classes/[id]/join/route.ts
Normal file
159
app/api/classes/[id]/join/route.ts
Normal 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
109
app/api/classes/route.ts
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
26
app/globals.css
Normal file
26
app/globals.css
Normal 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
29
app/layout.tsx
Normal 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
330
app/page.tsx
Normal 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">"+ Seed Demo Tenant"</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
components/classroom/JitsiClassroom.tsx
Normal file
227
components/classroom/JitsiClassroom.tsx
Normal 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
18
eslint.config.mjs
Normal 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
139
lib/jitsi.ts
Normal 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
119
lib/prisma.ts
Normal 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
11
lib/service-address.ts
Normal 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
14
next.config.ts
Normal 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
8804
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
135
prisma/schema.prisma
Normal 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
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
187
scripts/test-connections.ts
Normal 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();
|
||||||
362
scripts/test-jitsi-integration.ts
Normal file
362
scripts/test-jitsi-integration.ts
Normal 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();
|
||||||
227
src/components/classroom/JitsiClassroom.tsx
Normal file
227
src/components/classroom/JitsiClassroom.tsx
Normal 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
139
src/lib/jitsi.ts
Normal 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
34
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user