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",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.5.0",
|
"cn": "^0.5.0",
|
||||||
|
"jose": "^6.2.14",
|
||||||
"lucide-react": "^1.64.0",
|
"lucide-react": "^1.64.0",
|
||||||
"next": "16.4.3",
|
"next": "16.4.3",
|
||||||
"next-auth": "^5.0.0-beta.32",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"cn": "^0.5.0",
|
"cn": "^0.5.0",
|
||||||
|
"jose": "^6.2.14",
|
||||||
"lucide-react": "^1.64.0",
|
"lucide-react": "^1.64.0",
|
||||||
"next": "16.4.3",
|
"next": "16.4.3",
|
||||||
"next-auth": "^5.0.0-beta.32",
|
"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-start: var(--meet-bg-mint-start);
|
||||||
--color-meet-bg-mint-end: var(--meet-bg-mint-end);
|
--color-meet-bg-mint-end: var(--meet-bg-mint-end);
|
||||||
--color-meet-focus-ring: var(--meet-focus-ring);
|
--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-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
--font-heading: var(--font-sans);
|
--font-heading: var(--font-sans);
|
||||||
@@ -63,6 +69,12 @@
|
|||||||
--meet-bg-mint-start: #EAF7F4;
|
--meet-bg-mint-start: #EAF7F4;
|
||||||
--meet-bg-mint-end: #F2FBF0;
|
--meet-bg-mint-end: #F2FBF0;
|
||||||
--meet-focus-ring: #2BA8C4;
|
--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: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
|
|||||||
@@ -97,7 +97,11 @@ export default function RegisterPage() {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<Button className="w-full" render={<Link href="/login" />}>
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link href="/login" />}
|
||||||
|
>
|
||||||
Ir a iniciar sesión
|
Ir a iniciar sesión
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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