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:
WilandClaude Sonnet 5 committed 2026-10-29 11:57:42 -05:00
1 parent 6facce3d22
commit e26cfe624d
14 files changed
+715 -1

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+42
View File
@@ -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 }
)
}
+63
View File
@@ -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 }
)
}
+84
View File
@@ -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}
/>
)
}
+12
View File
@@ -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);
+5 -1
View File
@@ -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>
)
}
+58
View File
@@ -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>
)
}
+13
View File
@@ -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
}
+87
View File
@@ -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
}
}
+20
View File
@@ -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)",
},
}
+19
View File
@@ -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>