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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user