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:
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(),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user