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(),
})),
})
}