feat: add meeting creation and dashboard with join links
Adds the management/CRUD layer for meetings: a signed JWT join-link
token service (jose), instant/scheduled meeting creation endpoints,
and a protected /dashboard page listing the host's active, upcoming,
and past meetings with a "Nueva reunión" action area (instant button +
scheduling dialog). Also fixes a Base UI console warning (missing
nativeButton={false}) on Button instances rendered as Link.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6facce3d22
commit
e26cfe624d
14 files changed
+715
-1
No files matched your search
@@ -12,6 +12,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.5.0",
|
||||
"jose": "^6.2.14",
|
||||
"lucide-react": "^1.64.0",
|
||||
"next": "16.4.3",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.5.0",
|
||||
"jose": "^6.2.14",
|
||||
"lucide-react": "^1.64.0",
|
||||
"next": "16.4.3",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import crypto from "node:crypto"
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createJoinLinkToken } from "@/lib/join-link"
|
||||
|
||||
export async function POST() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
|
||||
}
|
||||
|
||||
const hostUserId = Number(session.user.id)
|
||||
const now = new Date()
|
||||
|
||||
// The creator is about to join immediately, so the meeting starts
|
||||
// `active` right away rather than sitting in `scheduled` until someone
|
||||
// joins.
|
||||
const meeting = await prisma.meeting.create({
|
||||
data: {
|
||||
uuid: crypto.randomUUID(),
|
||||
title: "Reunión instantánea",
|
||||
type: "instant",
|
||||
status: "active",
|
||||
hostUserId,
|
||||
startedAt: now,
|
||||
},
|
||||
})
|
||||
|
||||
const token = await createJoinLinkToken(meeting.uuid)
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
meeting: { id: meeting.id, uuid: meeting.uuid },
|
||||
joinUrl: `/join/${meeting.uuid}?token=${token}`,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import crypto from "node:crypto"
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createJoinLinkToken } from "@/lib/join-link"
|
||||
import { scheduledMeetingSchema } from "@/lib/validations/meeting"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "No autenticado" }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Cuerpo de solicitud inválido" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = scheduledMeetingSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Datos inválidos",
|
||||
issues: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const hostUserId = Number(session.user.id)
|
||||
const scheduledAt = new Date(parsed.data.scheduledAt)
|
||||
|
||||
const meeting = await prisma.meeting.create({
|
||||
data: {
|
||||
uuid: crypto.randomUUID(),
|
||||
title: parsed.data.title,
|
||||
type: "scheduled",
|
||||
status: "scheduled",
|
||||
hostUserId,
|
||||
scheduledAt,
|
||||
},
|
||||
})
|
||||
|
||||
const token = await createJoinLinkToken(meeting.uuid, { scheduledAt })
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
meeting: { id: meeting.id, uuid: meeting.uuid },
|
||||
joinUrl: `/join/${meeting.uuid}?token=${token}`,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { createJoinLinkToken } from "@/lib/join-link"
|
||||
import { DashboardContent } from "@/components/dashboard/dashboard-content"
|
||||
import type { MeetingDTO } from "@/components/dashboard/types"
|
||||
|
||||
// This route reads the session (cookies) and queries the DB for the
|
||||
// current user's meetings on every request, so it can't be part of the
|
||||
// static shell under Cache Components. Opting out of instant-navigation
|
||||
// validation here is the documented migration path (see
|
||||
// node_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.md)
|
||||
// for a route that should always render fresh data rather than a cached
|
||||
// shell — dashboard data (active/upcoming meetings) must never be stale.
|
||||
export const instant = false
|
||||
|
||||
const PAST_MEETINGS_LIMIT = 10
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login?callbackUrl=/dashboard")
|
||||
}
|
||||
|
||||
const hostUserId = Number(session.user.id)
|
||||
|
||||
const [activeMeetings, upcomingMeetings, pastMeetings] = await Promise.all([
|
||||
prisma.meeting.findMany({
|
||||
where: { hostUserId, status: "active" },
|
||||
orderBy: { startedAt: "desc" },
|
||||
}),
|
||||
prisma.meeting.findMany({
|
||||
where: {
|
||||
hostUserId,
|
||||
status: "scheduled",
|
||||
scheduledAt: { gte: new Date() },
|
||||
},
|
||||
orderBy: { scheduledAt: "asc" },
|
||||
}),
|
||||
prisma.meeting.findMany({
|
||||
where: { hostUserId, status: "ended" },
|
||||
orderBy: { endedAt: "desc" },
|
||||
take: PAST_MEETINGS_LIMIT,
|
||||
}),
|
||||
])
|
||||
|
||||
async function toDTO(
|
||||
meeting: (typeof activeMeetings)[number],
|
||||
withJoinLink: boolean
|
||||
): Promise<MeetingDTO> {
|
||||
return {
|
||||
id: meeting.id,
|
||||
uuid: meeting.uuid,
|
||||
title: meeting.title,
|
||||
type: meeting.type,
|
||||
status: meeting.status,
|
||||
scheduledAt: meeting.scheduledAt?.toISOString() ?? null,
|
||||
startedAt: meeting.startedAt?.toISOString() ?? null,
|
||||
endedAt: meeting.endedAt?.toISOString() ?? null,
|
||||
joinUrl: withJoinLink
|
||||
? `/join/${meeting.uuid}?token=${await createJoinLinkToken(meeting.uuid, {
|
||||
scheduledAt: meeting.scheduledAt,
|
||||
})}`
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
const [active, upcoming, past] = await Promise.all([
|
||||
Promise.all(activeMeetings.map((meeting) => toDTO(meeting, true))),
|
||||
Promise.all(upcomingMeetings.map((meeting) => toDTO(meeting, true))),
|
||||
Promise.all(pastMeetings.map((meeting) => toDTO(meeting, false))),
|
||||
])
|
||||
|
||||
return (
|
||||
<DashboardContent
|
||||
displayName={session.user.displayName}
|
||||
active={active}
|
||||
upcoming={upcoming}
|
||||
past={past}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,12 @@
|
||||
--color-meet-bg-mint-start: var(--meet-bg-mint-start);
|
||||
--color-meet-bg-mint-end: var(--meet-bg-mint-end);
|
||||
--color-meet-focus-ring: var(--meet-focus-ring);
|
||||
--color-meet-status-active-bg: var(--meet-status-active-bg);
|
||||
--color-meet-status-active-fg: var(--meet-status-active-fg);
|
||||
--color-meet-status-scheduled-bg: var(--meet-status-scheduled-bg);
|
||||
--color-meet-status-scheduled-fg: var(--meet-status-scheduled-fg);
|
||||
--color-meet-status-ended-bg: var(--meet-status-ended-bg);
|
||||
--color-meet-status-ended-fg: var(--meet-status-ended-fg);
|
||||
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
@@ -63,6 +69,12 @@
|
||||
--meet-bg-mint-start: #EAF7F4;
|
||||
--meet-bg-mint-end: #F2FBF0;
|
||||
--meet-focus-ring: #2BA8C4;
|
||||
--meet-status-active-bg: #E3F6EA;
|
||||
--meet-status-active-fg: #1F9D55;
|
||||
--meet-status-scheduled-bg: #E5F1FB;
|
||||
--meet-status-scheduled-fg: #1C6FB0;
|
||||
--meet-status-ended-bg: #F1F1F1;
|
||||
--meet-status-ended-fg: #6B7280;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
|
||||
@@ -97,7 +97,11 @@ export default function RegisterPage() {
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
<Button className="w-full" render={<Link href="/login" />}>
|
||||
<Button
|
||||
className="w-full"
|
||||
nativeButton={false}
|
||||
render={<Link href="/login" />}
|
||||
>
|
||||
Ir a iniciar sesión
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { signOut } from "next-auth/react"
|
||||
import { CalendarClock, History, LogOut, Radio, Video } from "lucide-react"
|
||||
|
||||
import { AppShell } from "@/components/shell/app-shell"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { MeetingCard } from "@/components/dashboard/meeting-card"
|
||||
import { ScheduleMeetingDialog } from "@/components/dashboard/schedule-meeting-dialog"
|
||||
import type { MeetingDTO } from "@/components/dashboard/types"
|
||||
|
||||
function EmptyState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-5 text-center text-sm text-muted-foreground">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardContent({
|
||||
displayName,
|
||||
active,
|
||||
upcoming,
|
||||
past,
|
||||
}: {
|
||||
displayName: string
|
||||
active: MeetingDTO[]
|
||||
upcoming: MeetingDTO[]
|
||||
past: MeetingDTO[]
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [creatingInstant, setCreatingInstant] = useState(false)
|
||||
const [instantError, setInstantError] = useState<string | null>(null)
|
||||
|
||||
async function handleInstantMeeting() {
|
||||
setInstantError(null)
|
||||
setCreatingInstant(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/meetings/instant", {
|
||||
method: "POST",
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setInstantError(data?.error ?? "No se pudo crear la reunión")
|
||||
setCreatingInstant(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push(data.joinUrl)
|
||||
} catch {
|
||||
setInstantError(
|
||||
"No se pudo conectar con el servidor. Intenta de nuevo."
|
||||
)
|
||||
setCreatingInstant(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-heading text-xl font-semibold text-(--meet-heading-blue)">
|
||||
Hola, {displayName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Gestiona tus reuniones de Appisoft Meet.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||
>
|
||||
<LogOut />
|
||||
Cerrar sesión
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Nueva reunión</CardTitle>
|
||||
<CardDescription>
|
||||
Inicia una reunión instantánea o programa una para más tarde.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap items-center gap-2">
|
||||
{instantError ? (
|
||||
<p className="w-full rounded-lg bg-destructive/10 p-2.5 text-sm text-destructive">
|
||||
{instantError}
|
||||
</p>
|
||||
) : null}
|
||||
<Button onClick={handleInstantMeeting} disabled={creatingInstant}>
|
||||
<Video />
|
||||
{creatingInstant ? "Creando..." : "Reunión instantánea"}
|
||||
</Button>
|
||||
<ScheduleMeetingDialog />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs defaultValue="active">
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">
|
||||
<Radio />
|
||||
Activas{active.length > 0 ? ` (${active.length})` : ""}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="upcoming">
|
||||
<CalendarClock />
|
||||
Próximas{upcoming.length > 0 ? ` (${upcoming.length})` : ""}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="past">
|
||||
<History />
|
||||
Pasadas
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="active" className="space-y-2 pt-1">
|
||||
{active.length === 0 ? (
|
||||
<EmptyState message="No tienes reuniones activas." />
|
||||
) : (
|
||||
active.map((meeting) => (
|
||||
<MeetingCard key={meeting.id} meeting={meeting} />
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upcoming" className="space-y-2 pt-1">
|
||||
{upcoming.length === 0 ? (
|
||||
<EmptyState message="No tienes reuniones próximas." />
|
||||
) : (
|
||||
upcoming.map((meeting) => (
|
||||
<MeetingCard key={meeting.id} meeting={meeting} />
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="past" className="space-y-2 pt-1">
|
||||
{past.length === 0 ? (
|
||||
<EmptyState message="No tienes reuniones pasadas." />
|
||||
) : (
|
||||
past.map((meeting) => (
|
||||
<MeetingCard key={meeting.id} meeting={meeting} />
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { MEETING_STATUS_BADGE } from "@/lib/meeting-status"
|
||||
import type { MeetingDTO } from "@/components/dashboard/types"
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("es-CO", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
})
|
||||
|
||||
function formatMeetingDateTime(meeting: MeetingDTO): string {
|
||||
const value =
|
||||
meeting.status === "ended"
|
||||
? meeting.endedAt
|
||||
: meeting.status === "active"
|
||||
? meeting.startedAt
|
||||
: meeting.scheduledAt
|
||||
|
||||
if (!value) {
|
||||
return "Sin fecha"
|
||||
}
|
||||
|
||||
return dateFormatter.format(new Date(value))
|
||||
}
|
||||
|
||||
export function MeetingCard({ meeting }: { meeting: MeetingDTO }) {
|
||||
const badge = MEETING_STATUS_BADGE[meeting.status]
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<CardContent className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="truncate text-sm font-medium">{meeting.title}</p>
|
||||
<Badge className={badge.className}>{badge.label}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMeetingDateTime(meeting)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{meeting.joinUrl ? (
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
nativeButton={false}
|
||||
render={<Link href={meeting.joinUrl} />}
|
||||
>
|
||||
Unirse
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { CalendarPlus, Loader2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
scheduledMeetingSchema,
|
||||
type ScheduledMeetingInput,
|
||||
} from "@/lib/validations/meeting"
|
||||
|
||||
export function ScheduleMeetingDialog() {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [serverError, setServerError] = useState<string | null>(null)
|
||||
|
||||
const form = useForm<ScheduledMeetingInput>({
|
||||
resolver: zodResolver(scheduledMeetingSchema),
|
||||
defaultValues: { title: "", scheduledAt: "" },
|
||||
})
|
||||
|
||||
async function onSubmit(values: ScheduledMeetingInput) {
|
||||
setServerError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/meetings/scheduled", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
setServerError(data?.error ?? "No se pudo programar la reunión")
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(false)
|
||||
form.reset()
|
||||
router.refresh()
|
||||
} catch {
|
||||
setServerError("No se pudo conectar con el servidor. Intenta de nuevo.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setServerError(null)
|
||||
form.reset()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger render={<Button variant="outline" />}>
|
||||
<CalendarPlus />
|
||||
Programar reunión
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Programar reunión</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define un título y la fecha y hora en que iniciará.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
{serverError ? (
|
||||
<p className="rounded-lg bg-destructive/10 p-2.5 text-sm text-destructive">
|
||||
{serverError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Título</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Reunión de equipo" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Fecha y hora</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="datetime-local" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Cancelar
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" />
|
||||
Programando...
|
||||
</>
|
||||
) : (
|
||||
"Programar"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MeetingStatus, MeetingType } from "@prisma/client"
|
||||
|
||||
export type MeetingDTO = {
|
||||
id: number
|
||||
uuid: string
|
||||
title: string
|
||||
type: MeetingType
|
||||
status: MeetingStatus
|
||||
scheduledAt: string | null
|
||||
startedAt: string | null
|
||||
endedAt: string | null
|
||||
joinUrl: string | null
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import "server-only"
|
||||
import { SignJWT, jwtVerify, errors as joseErrors } from "jose"
|
||||
|
||||
// Signed, time-limited join-link tokens: mirror the original Laravel
|
||||
// version's approach of encoding meeting access in a token rather than
|
||||
// relying on a guessable/incrementing URL. The token carries just enough
|
||||
// info (the meeting's UUID) for the future /join/[meetingUuid] page to
|
||||
// resolve the meeting and admit the bearer.
|
||||
|
||||
const secret = process.env.JOIN_LINK_SECRET
|
||||
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
"JOIN_LINK_SECRET environment variable is not set. Add it to .env (see .env.example)."
|
||||
)
|
||||
}
|
||||
|
||||
const encodedSecret = new TextEncoder().encode(secret)
|
||||
|
||||
// Grace period a join link stays valid for, counted either from issuance
|
||||
// (instant meetings) or from the scheduled start time (scheduled
|
||||
// meetings), whichever is further in the future.
|
||||
const GRACE_PERIOD_MS = 6 * 60 * 60 * 1000
|
||||
|
||||
interface JoinLinkTokenPayload {
|
||||
meetingUuid: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signed JWT encoding access to a meeting by UUID.
|
||||
*
|
||||
* For instant meetings, pass no `scheduledAt` and the token expires 6 hours
|
||||
* from now. For scheduled meetings, pass the meeting's `scheduledAt` so the
|
||||
* token stays valid through the grace period that follows the scheduled
|
||||
* start time, even if that's further out than 6 hours from now.
|
||||
*/
|
||||
export async function createJoinLinkToken(
|
||||
meetingUuid: string,
|
||||
options?: { scheduledAt?: Date | null }
|
||||
): Promise<string> {
|
||||
const now = Date.now()
|
||||
let expiresAtMs = now + GRACE_PERIOD_MS
|
||||
|
||||
if (options?.scheduledAt) {
|
||||
const scheduledExpiry = options.scheduledAt.getTime() + GRACE_PERIOD_MS
|
||||
expiresAtMs = Math.max(expiresAtMs, scheduledExpiry)
|
||||
}
|
||||
|
||||
return new SignJWT({ meetingUuid } satisfies JoinLinkTokenPayload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(Math.floor(expiresAtMs / 1000))
|
||||
.sign(encodedSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a join-link token. Returns `null` on any invalid/expired/
|
||||
* malformed token rather than throwing, so callers (e.g. the future
|
||||
* /join/[meetingUuid] page) can handle it gracefully (show "link expired",
|
||||
* fall back to requesting admission, etc.) instead of crashing.
|
||||
*/
|
||||
export async function verifyJoinLinkToken(
|
||||
token: string
|
||||
): Promise<{ meetingUuid: string } | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, encodedSecret, {
|
||||
algorithms: ["HS256"],
|
||||
})
|
||||
|
||||
if (typeof payload.meetingUuid !== "string" || !payload.meetingUuid) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { meetingUuid: payload.meetingUuid }
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof joseErrors.JWTExpired ||
|
||||
error instanceof joseErrors.JWSSignatureVerificationFailed ||
|
||||
error instanceof joseErrors.JWTInvalid
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Any other unexpected verification failure: still fail closed.
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MeetingStatus } from "@prisma/client"
|
||||
|
||||
export const MEETING_STATUS_BADGE: Record<
|
||||
MeetingStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
active: {
|
||||
label: "Activa",
|
||||
className: "bg-(--meet-status-active-bg) text-(--meet-status-active-fg)",
|
||||
},
|
||||
scheduled: {
|
||||
label: "Programada",
|
||||
className:
|
||||
"bg-(--meet-status-scheduled-bg) text-(--meet-status-scheduled-fg)",
|
||||
},
|
||||
ended: {
|
||||
label: "Finalizada",
|
||||
className: "bg-(--meet-status-ended-bg) text-(--meet-status-ended-fg)",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const scheduledMeetingSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, "El título es obligatorio")
|
||||
.max(200, "El título es demasiado largo"),
|
||||
scheduledAt: z
|
||||
.string()
|
||||
.min(1, "La fecha y hora son obligatorias")
|
||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
|
||||
message: "Fecha y hora inválidas",
|
||||
})
|
||||
.refine((value) => new Date(value).getTime() > Date.now(), {
|
||||
message: "La fecha debe ser en el futuro",
|
||||
}),
|
||||
})
|
||||
|
||||
export type ScheduledMeetingInput = z.infer<typeof scheduledMeetingSchema>
|
||||
Reference in new issue
Block a user