feat: add LiveKit join flow with waiting room and host admission

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
WilandClaude Sonnet 5 committed 2026-10-29 12:44:33 -05:00
1 parent e26cfe624d
commit 3c19455bdd
16 files changed
+1367

No files matched your search

@@ -0,0 +1,137 @@
import crypto from "node:crypto"
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { verifyJoinLinkToken } from "@/lib/join-link"
import { guestNameSchema } from "@/lib/validations/join"
import {
guestIdentityCookieName,
GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS,
} from "@/lib/participant-identity"
// Creates (or reuses) the current viewer's MeetingParticipant row, landing
// them in `pending` status unless they're the host. Called from the
// /join/[meetingUuid] name-entry / confirm step, not automatically — so a
// participant row is only created once the visitor explicitly asks to
// join.
export async function POST(
request: Request,
{ params }: { params: Promise<{ meetingUuid: string }> }
) {
const { meetingUuid } = await params
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: "Cuerpo de solicitud inválido" },
{ status: 400 }
)
}
const { token, guestName } = (body ?? {}) as {
token?: string
guestName?: string
}
if (typeof token !== "string" || !token) {
return NextResponse.json(
{ error: "Falta el token de invitación" },
{ status: 400 }
)
}
const verified = await verifyJoinLinkToken(token)
if (!verified || verified.meetingUuid !== meetingUuid) {
return NextResponse.json(
{ error: "El enlace de invitación no es válido o ha expirado" },
{ status: 400 }
)
}
const meeting = await prisma.meeting.findUnique({
where: { uuid: meetingUuid },
})
if (!meeting) {
return NextResponse.json(
{ error: "Reunión no encontrada" },
{ status: 404 }
)
}
if (meeting.status === "ended") {
return NextResponse.json(
{ error: "Esta reunión ha finalizado" },
{ status: 409 }
)
}
const session = await auth()
if (session?.user?.id) {
const userId = Number(session.user.id)
const identity = `user-${userId}`
const isHost = meeting.hostUserId === userId
const participant = await prisma.meetingParticipant.upsert({
where: { meetingId_identity: { meetingId: meeting.id, identity } },
update: {},
create: {
meetingId: meeting.id,
userId,
identity,
role: isHost ? "host" : "participant",
status: isHost ? "admitted" : "pending",
admittedAt: isHost ? new Date() : null,
},
})
return NextResponse.json({
participantId: participant.id,
status: participant.status,
})
}
// Anonymous guest: validate the display name and mint a fresh identity.
const parsed = guestNameSchema.safeParse({ guestName })
if (!parsed.success) {
return NextResponse.json(
{
error: "Datos inválidos",
issues: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const identity = `guest-${crypto.randomUUID()}`
const participant = await prisma.meetingParticipant.create({
data: {
meetingId: meeting.id,
guestName: parsed.data.guestName,
identity,
role: "participant",
status: "pending",
},
})
const response = NextResponse.json({
participantId: participant.id,
status: participant.status,
})
response.cookies.set(guestIdentityCookieName(meetingUuid), identity, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: GUEST_IDENTITY_COOKIE_MAX_AGE_SECONDS,
path: "/",
})
return response
}
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { isHostOrCoHost } from "@/lib/meeting-auth"
export async function POST(
_request: Request,
{ params }: { params: Promise<{ meetingUuid: string; participantId: string }> }
) {
const { meetingUuid, participantId } = await params
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
}
const meeting = await prisma.meeting.findUnique({
where: { uuid: meetingUuid },
})
if (!meeting) {
return NextResponse.json(
{ error: "Reunión no encontrada" },
{ status: 404 }
)
}
const userId = Number(session.user.id)
if (!(await isHostOrCoHost(meeting, userId))) {
return NextResponse.json(
{ error: "No tienes permiso para admitir participantes" },
{ status: 403 }
)
}
const participant = await prisma.meetingParticipant.findUnique({
where: { id: Number(participantId) },
})
if (!participant || participant.meetingId !== meeting.id) {
return NextResponse.json(
{ error: "Participante no encontrado" },
{ status: 404 }
)
}
const updated = await prisma.meetingParticipant.update({
where: { id: participant.id },
data: { status: "admitted", admittedAt: new Date() },
})
return NextResponse.json({ id: updated.id, status: updated.status })
}
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { isHostOrCoHost } from "@/lib/meeting-auth"
export async function POST(
_request: Request,
{ params }: { params: Promise<{ meetingUuid: string; participantId: string }> }
) {
const { meetingUuid, participantId } = await params
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
}
const meeting = await prisma.meeting.findUnique({
where: { uuid: meetingUuid },
})
if (!meeting) {
return NextResponse.json(
{ error: "Reunión no encontrada" },
{ status: 404 }
)
}
const userId = Number(session.user.id)
if (!(await isHostOrCoHost(meeting, userId))) {
return NextResponse.json(
{ error: "No tienes permiso para denegar participantes" },
{ status: 403 }
)
}
const participant = await prisma.meetingParticipant.findUnique({
where: { id: Number(participantId) },
})
if (!participant || participant.meetingId !== meeting.id) {
return NextResponse.json(
{ error: "Participante no encontrado" },
{ status: 404 }
)
}
const updated = await prisma.meetingParticipant.update({
where: { id: participant.id },
data: { status: "denied" },
})
return NextResponse.json({ id: updated.id, status: updated.status })
}
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { resolveViewerIdentity } from "@/lib/participant-identity"
// Polled by the waiting-room screen every ~3s until status flips to
// `admitted` or `denied`. Identity is resolved server-side from the
// session or guest cookie, never from a client-supplied value.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ meetingUuid: string }> }
) {
const { meetingUuid } = await params
const meeting = await prisma.meeting.findUnique({
where: { uuid: meetingUuid },
})
if (!meeting) {
return NextResponse.json(
{ error: "Reunión no encontrada" },
{ status: 404 }
)
}
const viewer = await resolveViewerIdentity(meetingUuid)
if (!viewer) {
return NextResponse.json({ status: null }, { status: 200 })
}
const participant = await prisma.meetingParticipant.findUnique({
where: {
meetingId_identity: { meetingId: meeting.id, identity: viewer.identity },
},
})
if (!participant) {
return NextResponse.json({ status: null }, { status: 200 })
}
return NextResponse.json({
status: participant.status,
role: participant.role,
})
}
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { isHostOrCoHost } from "@/lib/meeting-auth"
// Lists participants for the host/co-host admission panel — polled from
// /room/[meetingUuid] to surface pending join requests.
export async function GET(
_request: Request,
{ params }: { params: Promise<{ meetingUuid: string }> }
) {
const { meetingUuid } = await params
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
}
const meeting = await prisma.meeting.findUnique({
where: { uuid: meetingUuid },
})
if (!meeting) {
return NextResponse.json(
{ error: "Reunión no encontrada" },
{ status: 404 }
)
}
const userId = Number(session.user.id)
if (!(await isHostOrCoHost(meeting, userId))) {
return NextResponse.json(
{ error: "No tienes permiso para ver esta lista" },
{ status: 403 }
)
}
const participants = await prisma.meetingParticipant.findMany({
where: { meetingId: meeting.id },
orderBy: { requestedAt: "asc" },
include: { user: { select: { displayName: true, email: true } } },
})
return NextResponse.json({
participants: participants.map((participant) => ({
id: participant.id,
identity: participant.identity,
displayName:
participant.user?.displayName ?? participant.guestName ?? "Invitado",
role: participant.role,
status: participant.status,
requestedAt: participant.requestedAt.toISOString(),
})),
})
}
+217
View File
@@ -0,0 +1,217 @@
import { cookies } from "next/headers"
import { redirect } from "next/navigation"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { verifyJoinLinkToken } from "@/lib/join-link"
import { guestIdentityCookieName } from "@/lib/participant-identity"
import { AppShell } from "@/components/shell/app-shell"
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { JoinFlow } from "@/components/join/join-flow"
// Reads the session/cookies and queries the DB on every request to
// resolve "who is this visitor and what's their access state" — same
// reasoning as the dashboard page (see its own comment) for opting out
// of Cache Components' static-shell validation.
export const instant = false
function JoinShell({
title,
children,
}: {
title?: string
children: React.ReactNode
}) {
return (
<AppShell className="flex min-h-[80vh] items-center justify-center">
<div className="w-full max-w-md">
{title ? (
<h1 className="mb-4 text-center text-lg font-semibold text-foreground">
{title}
</h1>
) : null}
{children}
</div>
</AppShell>
)
}
function JoinMessage({
title,
description,
}: {
title: string
description: string
}) {
return (
<JoinShell>
<Card>
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
</Card>
</JoinShell>
)
}
export default async function JoinPage({
params,
searchParams,
}: {
params: Promise<{ meetingUuid: string }>
searchParams: Promise<{ token?: string }>
}) {
const { meetingUuid } = await params
const { token } = await searchParams
if (!token) {
return (
<JoinMessage
title="Enlace inválido"
description="Falta el token de invitación en el enlace. Pide al anfitrión que te comparta el enlace completo."
/>
)
}
const verified = await verifyJoinLinkToken(token)
if (!verified || verified.meetingUuid !== meetingUuid) {
return (
<JoinMessage
title="Enlace inválido o expirado"
description="Este enlace de invitación ya no es válido. Pide al anfitrión un nuevo enlace."
/>
)
}
const meeting = await prisma.meeting.findUnique({
where: { uuid: meetingUuid },
})
if (!meeting) {
return (
<JoinMessage
title="Reunión no encontrada"
description="No encontramos esta reunión. Verifica el enlace."
/>
)
}
if (meeting.status === "ended") {
return (
<JoinMessage
title="Esta reunión ha finalizado"
description="El anfitrión ya terminó esta reunión. No es posible unirse."
/>
)
}
const session = await auth()
if (session?.user?.id) {
const userId = Number(session.user.id)
const identity = `user-${userId}`
if (meeting.hostUserId === userId) {
await prisma.meetingParticipant.upsert({
where: {
meetingId_identity: { meetingId: meeting.id, identity },
},
update: {},
create: {
meetingId: meeting.id,
userId,
identity,
role: "host",
status: "admitted",
admittedAt: new Date(),
},
})
redirect(`/room/${meetingUuid}`)
}
const participant = await prisma.meetingParticipant.findUnique({
where: {
meetingId_identity: { meetingId: meeting.id, identity },
},
})
if (participant) {
if (participant.status === "admitted") {
redirect(`/room/${meetingUuid}`)
}
if (participant.status === "denied") {
return (
<JoinShell>
<JoinFlow meetingUuid={meetingUuid} token={token} mode="denied" />
</JoinShell>
)
}
return (
<JoinShell>
<JoinFlow meetingUuid={meetingUuid} token={token} mode="waiting" />
</JoinShell>
)
}
return (
<JoinShell>
<JoinFlow
meetingUuid={meetingUuid}
token={token}
mode="confirm"
displayName={session.user.displayName}
/>
</JoinShell>
)
}
// Anonymous visitor: recognize a returning guest via cookie before
// falling back to the name-entry form.
const cookieStore = await cookies()
const guestIdentity = cookieStore.get(
guestIdentityCookieName(meetingUuid)
)?.value
if (guestIdentity) {
const participant = await prisma.meetingParticipant.findUnique({
where: {
meetingId_identity: { meetingId: meeting.id, identity: guestIdentity },
},
})
if (participant) {
if (participant.status === "admitted") {
redirect(`/room/${meetingUuid}`)
}
if (participant.status === "denied") {
return (
<JoinShell>
<JoinFlow meetingUuid={meetingUuid} token={token} mode="denied" />
</JoinShell>
)
}
return (
<JoinShell>
<JoinFlow meetingUuid={meetingUuid} token={token} mode="waiting" />
</JoinShell>
)
}
}
return (
<JoinShell>
<JoinFlow meetingUuid={meetingUuid} token={token} mode="name-entry" />
</JoinShell>
)
}
+118
View File
@@ -0,0 +1,118 @@
import Link from "next/link"
import { prisma } from "@/lib/prisma"
import { resolveViewerIdentity } from "@/lib/participant-identity"
import { mintAccessToken, LIVEKIT_URL } from "@/lib/livekit"
import { AppShell } from "@/components/shell/app-shell"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { RoomClient } from "@/components/room/room-client"
// Reads the session/guest cookie and queries the DB on every request to
// resolve admission state and mint a fresh LiveKit token — see the
// dashboard/join pages' own comments for why this opts out of Cache
// Components' static-shell validation.
export const instant = false
function NoAccess() {
return (
<AppShell className="flex min-h-[80vh] items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>No tienes acceso a esta sala</CardTitle>
<CardDescription>
Usa el enlace de invitación que te compartió el anfitrión para
unirte a esta reunión.
</CardDescription>
</CardHeader>
<CardContent>
<Button render={<Link href="/dashboard" />} nativeButton={false}>
Ir al panel
</Button>
</CardContent>
</Card>
</AppShell>
)
}
export default async function RoomPage({
params,
}: {
params: Promise<{ meetingUuid: string }>
}) {
const { meetingUuid } = await params
const meeting = await prisma.meeting.findUnique({
where: { uuid: meetingUuid },
})
if (!meeting) {
return <NoAccess />
}
if (meeting.status === "ended") {
return (
<AppShell className="flex min-h-[80vh] items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Esta reunión ha finalizado</CardTitle>
<CardDescription>
El anfitrión ya terminó esta reunión.
</CardDescription>
</CardHeader>
<CardContent>
<Button render={<Link href="/dashboard" />} nativeButton={false}>
Ir al panel
</Button>
</CardContent>
</Card>
</AppShell>
)
}
const viewer = await resolveViewerIdentity(meetingUuid)
if (!viewer) {
return <NoAccess />
}
const participant = await prisma.meetingParticipant.findUnique({
where: {
meetingId_identity: { meetingId: meeting.id, identity: viewer.identity },
},
include: { user: { select: { displayName: true } } },
})
if (!participant || participant.status !== "admitted") {
return <NoAccess />
}
const isHost = participant.role === "host" || participant.role === "co_host"
const displayName =
participant.user?.displayName ?? participant.guestName ?? "Invitado"
const token = await mintAccessToken({
roomName: meeting.uuid,
identity: viewer.identity,
displayName,
canPublish: true,
canSubscribe: true,
isHost,
})
return (
<RoomClient
serverUrl={LIVEKIT_URL}
token={token}
identity={viewer.identity}
roomName={meeting.uuid}
meetingUuid={meetingUuid}
isHost={isHost}
/>
)
}