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} - 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 ? ( + + ) : 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 ( + { + setOpen(nextOpen) + if (!nextOpen) { + setServerError(null) + form.reset() + } + }} + > + }> + + Programar reunión + + + + Programar reunión + + Define un título y la fecha y hora en que iniciará. + + + +
+ + {serverError ? ( +

+ {serverError} +

+ ) : null} + + ( + + Título + + + + + + )} + /> + + ( + + Fecha y hora + + + + + + )} + /> + + + }> + Cancelar + + + + + +
+
+ ) +} 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