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