From e26cfe624d0c0e759123283ec39b8d7f13a9b994 Mon Sep 17 00:00:00 2001
From: Wil
Date: Thu, 29 Oct 2026 11:57:42 -0500
Subject: [PATCH] feat: add meeting creation and dashboard with join links
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
bun.lock | 1 +
package.json | 1 +
src/app/api/meetings/instant/route.ts | 42 +++++
src/app/api/meetings/scheduled/route.ts | 63 +++++++
src/app/dashboard/page.tsx | 84 +++++++++
src/app/globals.css | 12 ++
src/app/register/page.tsx | 6 +-
.../dashboard/dashboard-content.tsx | 162 ++++++++++++++++++
src/components/dashboard/meeting-card.tsx | 58 +++++++
.../dashboard/schedule-meeting-dialog.tsx | 148 ++++++++++++++++
src/components/dashboard/types.ts | 13 ++
src/lib/join-link.ts | 87 ++++++++++
src/lib/meeting-status.ts | 20 +++
src/lib/validations/meeting.ts | 19 ++
14 files changed, 715 insertions(+), 1 deletion(-)
create mode 100644 src/app/api/meetings/instant/route.ts
create mode 100644 src/app/api/meetings/scheduled/route.ts
create mode 100644 src/app/dashboard/page.tsx
create mode 100644 src/components/dashboard/dashboard-content.tsx
create mode 100644 src/components/dashboard/meeting-card.tsx
create mode 100644 src/components/dashboard/schedule-meeting-dialog.tsx
create mode 100644 src/components/dashboard/types.ts
create mode 100644 src/lib/join-link.ts
create mode 100644 src/lib/meeting-status.ts
create mode 100644 src/lib/validations/meeting.ts
diff --git a/bun.lock b/bun.lock
index 4c61ecd..de1d01b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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",
diff --git a/package.json b/package.json
index 948205b..e4c725b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/app/api/meetings/instant/route.ts b/src/app/api/meetings/instant/route.ts
new file mode 100644
index 0000000..3233b32
--- /dev/null
+++ b/src/app/api/meetings/instant/route.ts
@@ -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 }
+ )
+}
diff --git a/src/app/api/meetings/scheduled/route.ts b/src/app/api/meetings/scheduled/route.ts
new file mode 100644
index 0000000..a14e902
--- /dev/null
+++ b/src/app/api/meetings/scheduled/route.ts
@@ -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 }
+ )
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..5bdc56a
--- /dev/null
+++ b/src/app/dashboard/page.tsx
@@ -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 {
+ 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 (
+
+ )
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 1c939f5..2d9c7b4 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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);
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
index 001a158..cce693e 100644
--- a/src/app/register/page.tsx
+++ b/src/app/register/page.tsx
@@ -97,7 +97,11 @@ export default function RegisterPage() {
) : null}
- }>
+ }
+ >
Ir a iniciar sesión
diff --git a/src/components/dashboard/dashboard-content.tsx b/src/components/dashboard/dashboard-content.tsx
new file mode 100644
index 0000000..cb36ccf
--- /dev/null
+++ b/src/components/dashboard/dashboard-content.tsx
@@ -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 (
+
+ {message}
+
+ )
+}
+
+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(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 (
+
+
+
+
+
+ Hola, {displayName}
+
+
+ Gestiona tus reuniones de Appisoft Meet.
+
+
+
+
+
+
+
+ Nueva reunión
+
+ Inicia una reunión instantánea o programa una para más tarde.
+
+
+
+ {instantError ? (
+
+ {instantError}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ Activas{active.length > 0 ? ` (${active.length})` : ""}
+
+
+
+ Próximas{upcoming.length > 0 ? ` (${upcoming.length})` : ""}
+
+
+
+ Pasadas
+
+
+
+
+ {active.length === 0 ? (
+
+ ) : (
+ active.map((meeting) => (
+
+ ))
+ )}
+
+
+
+ {upcoming.length === 0 ? (
+
+ ) : (
+ upcoming.map((meeting) => (
+
+ ))
+ )}
+
+
+
+ {past.length === 0 ? (
+
+ ) : (
+ past.map((meeting) => (
+
+ ))
+ )}
+
+
+
+
+ )
+}
diff --git a/src/components/dashboard/meeting-card.tsx b/src/components/dashboard/meeting-card.tsx
new file mode 100644
index 0000000..94d305c
--- /dev/null
+++ b/src/components/dashboard/meeting-card.tsx
@@ -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 (
+
+
+
+
+
{meeting.title}
+
{badge.label}
+
+
+ {formatMeetingDateTime(meeting)}
+
+
+
+ {meeting.joinUrl ? (
+ }
+ >
+ Unirse
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/components/dashboard/schedule-meeting-dialog.tsx b/src/components/dashboard/schedule-meeting-dialog.tsx
new file mode 100644
index 0000000..e82cfd8
--- /dev/null
+++ b/src/components/dashboard/schedule-meeting-dialog.tsx
@@ -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(null)
+
+ const form = useForm({
+ 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 (
+
+ )
+}
diff --git a/src/components/dashboard/types.ts b/src/components/dashboard/types.ts
new file mode 100644
index 0000000..e05edb4
--- /dev/null
+++ b/src/components/dashboard/types.ts
@@ -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
+}
diff --git a/src/lib/join-link.ts b/src/lib/join-link.ts
new file mode 100644
index 0000000..65e7c72
--- /dev/null
+++ b/src/lib/join-link.ts
@@ -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 {
+ 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
+ }
+}
diff --git a/src/lib/meeting-status.ts b/src/lib/meeting-status.ts
new file mode 100644
index 0000000..e0c0f21
--- /dev/null
+++ b/src/lib/meeting-status.ts
@@ -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)",
+ },
+}
diff --git a/src/lib/validations/meeting.ts b/src/lib/validations/meeting.ts
new file mode 100644
index 0000000..7d27ca8
--- /dev/null
+++ b/src/lib/validations/meeting.ts
@@ -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