diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2bf9695f..0827dbae 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -102,3 +102,28 @@ model Plan { updatedAt DateTime @updatedAt subscriptions Subscription[] } + +model WeeklySession { + id String @id @default(cuid()) + title String + description String? + youtubeUrl String + sessionDate DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + topics SessionTopic[] + + @@index([sessionDate]) + @@index([createdAt]) +} + +model SessionTopic { + id String @id @default(cuid()) + sessionId String + timestamp String + topic String + order Int + session WeeklySession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@index([sessionId, order]) +} \ No newline at end of file diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts index 782b4361..252e48b3 100644 --- a/apps/api/src/routers/_app.ts +++ b/apps/api/src/routers/_app.ts @@ -4,6 +4,7 @@ import { userRouter } from "./user.js"; import { projectRouter } from "./projects.js"; import { authRouter } from "./auth.js"; import { paymentRouter } from "./payment.js"; +import { sessionsRouter } from "./sessions.js"; import { z } from "zod"; const testRouter = router({ @@ -21,6 +22,7 @@ export const appRouter = router({ project: projectRouter, auth: authRouter, payment: paymentRouter, + sessions: sessionsRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/api/src/routers/sessions.ts b/apps/api/src/routers/sessions.ts new file mode 100644 index 00000000..5bab2f84 --- /dev/null +++ b/apps/api/src/routers/sessions.ts @@ -0,0 +1,11 @@ +import { router, protectedProcedure } from "../trpc.js"; +import { sessionService } from "../services/session.service.js"; + +export const sessionsRouter = router({ + // get all sessions for authenticated paid users + getAll: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.user.id; + return await sessionService.getSessions(ctx.db.prisma, userId); + }), +}); + diff --git a/apps/api/src/services/session.service.ts b/apps/api/src/services/session.service.ts new file mode 100644 index 00000000..6e594925 --- /dev/null +++ b/apps/api/src/services/session.service.ts @@ -0,0 +1,90 @@ +import type { Prisma, PrismaClient } from "@prisma/client"; +import type { ExtendedPrismaClient } from "../prisma.js"; +import { SUBSCRIPTION_STATUS } from "../constants/subscription.js"; + +export type SessionWithTopics = Prisma.WeeklySessionGetPayload<{ + select: { + id: true; + title: true; + description: true; + youtubeUrl: true; + sessionDate: true; + topics: { + select: { + id: true; + timestamp: true; + topic: true; + order: true; + }; + }; + }; +}>; + +export const sessionService = { + /** + * Get all sessions for authenticated paid users + * Sessions are ordered by sessionDate descending (newest first) + */ + async getSessions( + prisma: ExtendedPrismaClient | PrismaClient, + userId: string + ): Promise { + try { + // verify user has active subscription + const subscription = await prisma.subscription.findFirst({ + where: { + userId, + status: SUBSCRIPTION_STATUS.ACTIVE, + endDate: { + gte: new Date(), + }, + }, + }); + + if (!subscription) { + throw new Error("Active subscription required to access sessions"); + } + } catch (error) { + // log error with context before rethrowing + // rethrowing ensures automatic transaction rollback if this is part of a transaction + const timestamp = new Date().toISOString(); + const functionName = "getSessions"; + + console.error( + `[${timestamp}] Error in sessionService.${functionName} - userId: ${userId}, endpoint: ${functionName}`, + error + ); + + // rethrow to ensure transaction rollback (if in transaction) and proper error propagation + throw error; + } + + // fetch only fields needed by the web ui; keep topics ordered + const sessions = await prisma.weeklySession.findMany({ + select: { + id: true, + title: true, + description: true, + youtubeUrl: true, + sessionDate: true, + topics: { + select: { + id: true, + timestamp: true, + topic: true, + order: true, + }, + orderBy: { + order: "asc", + }, + }, + }, + orderBy: { + sessionDate: "desc", + }, + }); + + return sessions; + }, +}; + diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts index d9049676..ff97b6ae 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -37,4 +37,4 @@ const isAuthed = t.middleware(async ({ ctx, next }) => { export const router = t.router; export const publicProcedure = t.procedure; -export const protectedProcedure:any = t.procedure.use(isAuthed); +export const protectedProcedure = t.procedure.use(isAuthed); diff --git a/apps/web/package.json b/apps/web/package.json index c302d2b6..f7b9194f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,9 +17,9 @@ "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", - "@trpc/client": "^11.6.0", - "@trpc/react-query": "^11.6.0", - "@trpc/server": "^11.5.1", + "@trpc/client": "^11.7.2", + "@trpc/react-query": "^11.7.2", + "@trpc/server": "^11.7.2", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", "class-variance-authority": "^0.7.0", diff --git a/apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx b/apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx index fae7f9ba..13242854 100644 --- a/apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx +++ b/apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx @@ -4,6 +4,8 @@ import { useSubscription } from "@/hooks/useSubscription"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; +import Link from "next/link"; +import { Play } from "lucide-react"; export default function ProDashboardPage() { const { isPaidUser, isLoading } = useSubscription(); @@ -109,17 +111,26 @@ export default function ProDashboardPage() { soon you'll see all the pro perks here. thanks for investin! {isPaidUser && ( -
+
+ + + Pro Sessions + - {error &&

{error}

}
)} + {error && ( +

{error}

+ )}
); diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionCard.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionCard.tsx new file mode 100644 index 00000000..16d2d8e1 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionCard.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Play } from "lucide-react"; + +import type { WeeklySession } from "./session-types"; + +type SessionCardProps = { + session: WeeklySession; + onPlayAction: (session: WeeklySession) => void; +}; + +export function SessionCard({ + session, + onPlayAction, +}: SessionCardProps): JSX.Element | null { + return ( +
+
+
+

+ {session.title} +

+ {session.description ? ( +

+ {session.description} +

+ ) : null} +
+ + +
+
+ ); +} + + diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionVideoDialog.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionVideoDialog.tsx new file mode 100644 index 00000000..d0bcff5a --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionVideoDialog.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; + +import { CheckCircle2, X } from "lucide-react"; + +import { getYoutubeEmbedUrl } from "./youtube"; +import type { WeeklySession } from "./session-types"; + +type SessionVideoDialogProps = { + isOpen: boolean; + session: WeeklySession | null; + onCloseAction: () => void; +}; + +export function SessionVideoDialog({ + isOpen, + session, + onCloseAction, +}: SessionVideoDialogProps): JSX.Element | null { + const closeButtonRef = useRef(null); + + const embedUrl = useMemo(() => { + if (!session?.youtubeUrl) return null; + return getYoutubeEmbedUrl(session.youtubeUrl); + }, [session?.youtubeUrl]); + + useEffect(() => { + if (!isOpen) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + closeButtonRef.current?.focus(); + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isOpen]); + + if (!isOpen || !session) return null; + + return ( +
{ + if (e.key === "Escape") onCloseAction(); + }} + > + +
+ +
+
+
+ {embedUrl ? ( +